Skip to main content
Plexara
Product12 min read

602 - The agent writes the first script

From a solved session to a saved script, with the loop the agent works through: create, validate, dry-run, patch, validate, dry-run, save. What a validate report says about the tools, connections, and destinations a script reaches; what a dry run measures without persisting anything; the dialect’s deliberate absences and the three traps that fail a draft; typed parameters; and the script’s own page in the portal with its source, Validate, Dry run, and versions. Grounded in a real script that reads the 500 series’ registered table.

What you will take away from this lesson

Lesson 504 worked out the supplier margin analysis in a session: three statements against the registered price sheet, a cast on the cost column, a threshold for the at-risk list, and a coverage count. That logic is settled. This lesson asks the agent to write it down as a script, and follows what happened on the demo tenant, including the two drafts that failed before one ran.

The sentence that starts it is short: turn the margin analysis into a script that takes a month and a connection, runs the three queries, and publishes a category table and a JSON feed. Everything after that is the agent’s loop, and your review.

Learning Objectives

  1. 01Start from a solved session and describe the job so the agent can write the script.
  2. 02Follow the loop the agent works through: create, validate, dry-run, patch, validate, dry-run, save.
  3. 03Read a validate report: the tools, connections, and destinations a script reaches, and what it reports as unreadable.
  4. 04Read a dry run: executed as you, under draft limits, nothing persisted, each output measured.
  5. 05Know the dialect’s deliberate absences and the traps that fail a draft: a DECIMAL that arrives as text, a result that hits the row cap, SQL built by hand, a format the language does not have.
  6. 06Know how a script reads a file: through the registered table, in SQL, never by parsing the bytes.
  7. 07Declare typed parameters, document the script so search finds it, and read its page in the portal: source, Validate, Dry run, versions.

Where this lesson sits

The second lesson of the 600 series, and the first hands-on one. It produces the script the next four lessons run, refresh, schedule, and compose.

600 Series: Automations

Open index

The 600 series picks up the registered table from the 500 series and the prompt mechanics from the 400 series. It is the last numbered series in the curriculum.

The dialect in one screen

Scripts are written in Starlark, which looks like Python and is deliberately smaller. The agent reads the dialect contract from the platform before it writes a line, and the contract is short enough to fit on one screen: what is available, what is not, and why each absence is there.

The dialect in one screen

  • platform.query, platform.export, platform.publish_data, platform.call

    The four capabilities. Read-only SQL with :name parameters, a named output, a data-region refresh, and any other Plexara tool by name.

    import

    There is no module system. json and date are already here.

  • json, date, sum, and the builtins

    json.encode and decode; date.of, add_days, add_months, start_of_month and the rest, all as YYYY-MM-DD strings; len, range, sorted, min, max, str, int, float, fail, and the string, list, and dict methods.

    try / except

    Errors fail the run by design, so the failure is recorded rather than swallowed. Check first, or call fail("why").

  • run.run_id, run.fire_time, run.params

    The frozen run record. The fire time is the only clock a script has, so a re-run months later asks the same question.

    while, recursion

    Unbounded loops are off so a script’s cost is readable from its source. Loop over a list, or do it in SQL.

  • print(...)

    Goes to the run log, which is kept with the run and capped at 64 KB.

    f"...", class

    Use "{}".format(x) or "%s" % x. Use dicts for structured values and functions for behavior.

  • def, for, if, list comprehensions

    Ordinary functions and bounded loops. A sort key is a named function, since there is no lambda.

    datetime, now(), random

    There is no clock and no randomness. Reading one would make the run unreproducible.

  • A named connection

    A script names the connection it queries; Plexara holds the credentials and authorizes each call.

    open, requests, credentials

    There is no filesystem and no direct network. The platform is the only outside world a script has. A credential never goes in the source.

The absences are the point. Same script version, same parameters, same underlying data produce the same output; the script contributes no variation of its own. The agent reads this contract from the platform before it writes a line, which is why its first draft is already in the dialect.

How a script reads a file

The first question people ask about a script that works a spreadsheet is how it opens the file. It does not. The 500 series already did the hard part.

The first script

The agent turned the 504 session into the script below. It takes a month and a connection, runs the three statements from the session against the registered sheet, computes the weighted change in Starlark, prints a four-line summary to the run log, and exports a category table as CSV and a feed as JSON under names that will hold across every run.

acme-supplier-margin, the saved version that first ran

month_start = date.start_of_month(run.params["month"])
month_end = date.add_months(month_start, 1)
conn = run.params["conn"]
print("supplier margin review for %s to %s" % (month_start, month_end))

NOV = """
WITH sold AS (
  SELECT ti.product_id, SUM(ti.quantity) AS units
  FROM warehouse.public.transaction_items ti
  JOIN warehouse.public.transactions t ON t.transaction_id = ti.transaction_id
  WHERE t.transaction_date >= CAST(DATE :start AS TIMESTAMP)
    AND t.transaction_date < CAST(DATE :end AS TIMESTAMP)
  GROUP BY ti.product_id
)
"""

categories = platform.query(
    connection = conn,
    sql = NOV + """
SELECT c.category_name AS category, c.department, COUNT(*) AS quoted_products, SUM(s2.units) AS units,
  ROUND(SUM(s2.units * p.cost), 2) AS cost_of_record,
  ROUND(SUM(s2.units * CAST(s.unit_cost AS DECIMAL(10,2))), 2) AS quoted_cost,
  ROUND(100.0 * (SUM(s2.units * CAST(s.unit_cost AS DECIMAL(10,2))) - SUM(s2.units * p.cost))
        / SUM(s2.units * p.cost), 1) AS cost_change_pct,
  ROUND(100.0 * (SUM(s2.units * p.price) - SUM(s2.units * CAST(s.unit_cost AS DECIMAL(10,2))))
        / SUM(s2.units * p.price), 1) AS margin_projected_pct
FROM scratch.uploads.admin_supplier_price_sheet s
JOIN warehouse.public.products p ON p.sku = s.sku
JOIN warehouse.public.categories c ON c.category_id = p.category_id
JOIN sold s2 ON s2.product_id = p.product_id
GROUP BY c.category_name, c.department
ORDER BY cost_change_pct DESC
""",
    params = {"start": month_start, "end": month_end},
)

at_risk = platform.query(connection = conn, sql = NOV + AT_RISK_SQL,
                         params = {"start": month_start, "end": month_end})
coverage = platform.query(connection = conn, sql = NOV + COVERAGE_SQL,
                          params = {"start": month_start, "end": month_end})

cat_rows = categories["rows"]
if len(cat_rows) == 0:
    fail("no sales joined to the price sheet for %s; nothing to publish" % month_start)

cov = coverage["rows"][0]
total_record = sum([float(r["cost_of_record"]) for r in cat_rows])
total_quoted = sum([float(r["quoted_cost"]) for r in cat_rows])
weighted_change = 100.0 * (total_quoted - total_record) / total_record

def by_change(r):
    return -float(r["cost_change_pct"])

movers = sorted(cat_rows, key = by_change)
print("categories quoted: %d, weighted cost change %s%%" % (len(cat_rows), int(weighted_change * 100) / 100.0))
print("largest increase: %s %s%%" % (movers[0]["category"], movers[0]["cost_change_pct"]))

platform.export(name = "supplier-margin-by-category", rows = cat_rows, format = "csv")
platform.export(
    name = "supplier-margin-feed",
    rows = [{"as_of": run.fire_time, "month": month_start, "coverage": cov,
             "categories": cat_rows, "at_risk": at_risk["rows"]}],
    format = "json",
)

Abridged from the real source, which runs to about 114 lines with the two other statements written out. Every choice from the 504 session is here: the shared CTE with the month bound as DATE :start and DATE :end, the cast on unit_cost, the join on sku with no cast, the fail guard for a month with no sales, the float() on every DECIMAL, a named sort key because there is no lambda, and two outputs under stable names. The agent wrote it; the person reviewing it had to know what it should do, not how to write it.

Typed parameters

The two values that change between runs are declared as parameters with types, not read from free text. The types decide what every surface that asks for them shows.

The parameter contract

month
date, default 2025-11-01
Any date inside the calendar month to report on. The script takes the start of that month and the month after it, so a schedule can bind the fire date and get the right month.
conn
connection, default acme
A connection parameter binds as a string, but every surface that asks for it offers the connections this script may reach instead of a blank box, and a name outside them is refused where it was entered.

A parameter is typed string, int, float, bool, date, enum, or connection, and every surface that asks for a value renders the control the type deserves. An optional enum, date, or connection declares a default; there is no meaningful empty connection. Values are checked against the contract before anything is queued.

The loop: create, validate, dry-run, patch, save

The agent does not write the script and hand it over. It saves a first version, checks it statically, executes it as a draft against real data, fixes what the draft turns up, and repeats until the draft succeeds. On the demo tenant that took two fixes. The rail below is the real sequence, and the trace under it is what the agent saw at each step.

The loop, as it happened on the demo tenant

  1. 1

    Create

    The agent saves a first version with its parameters, a display name, a category, tags, and a markdown description. The answer says it already runs, and points at run_draft for iterating before the next save.

  2. 2

    Validate

    A static read of the source. It reports the two capabilities used, the destination (portal), no tools called by name, and that the connection is computed from a parameter, so the connection list is incomplete. Nothing runs.

  3. 3

    Dry run, fails

    Executed as the author with nothing persisted. Three queries ran, 1,705 steps, 6.1 seconds, then line 92: unknown conversion, because the print used a %.2f format the dialect does not have.

  4. 4

    Patch

    One anchored edit replaces the format with "%s" and an integer rounding. Version 2, and the answer says this version is what runs now.

  5. 5

    Dry run, fails again

    The CSV export was measured (50 rows, 3,378 bytes) before line 103 refused the JSON export: rows must be a list of dicts, and the feed was a dict.

  6. 6

    Patch

    The feed is wrapped in a list. Version 3.

  7. 7

    Dry run, succeeds

    Three queries, 1,807 steps, 5.1 seconds, two outputs measured and nothing written: the CSV at 50 rows and 3,378 bytes, the JSON feed at 27,626 bytes. The log shows 50 categories quoted and 400 of 450 products covered.

  8. 8

    The saved version runs

    Version 3 is what run_script executes and what a schedule fires. The first real run, for December, wrote both outputs as versioned assets in 8.5 seconds.

Two failed drafts is normal, and it cost nothing: a draft persists nothing, and a script failure is deterministic, so the platform says so and asks for a fix rather than retrying. The person’s part was reading the three lines of log at the end and agreeing they matched the 504 session.

The same loop as the agent sees it

Each step is one call to Plexara and one answer. The answers are worth reading because they are written for the agent to act on: which version is live, which line failed, what to do instead.

The same loop as calls, with the real answers

What the agent calls. This is the exchange the agent has with Plexara on your behalf, shown for the technical reader. You ask in plain language; you never type any of it.

manage_script command=create  name=acme-supplier-margin
  params=[month: date (default 2025-11-01), conn: connection (default acme)]
  display_name="Supplier Quote Margin Review"  category=merchandising
→ status created   version 1
  "Saved, and it runs: run_script executes it under the access you held
   when you saved it, and a schedule you set will fire it. Use run_draft
   to iterate on changes before saving them."

manage_script command=validate  name=acme-supplier-margin
→ ok true   capabilities [platform.export, platform.query]   destinations [portal]
  connections []   dynamic_connections true   tools []   findings null
  "At least one call computes its connection instead of naming one ...
   so this connection list is incomplete."

manage_script command=run_draft  name=acme-supplier-margin  args={month: 2025-11-01}
→ status failed   queries 3   steps 1705   duration 6,105 ms   run dpx_0107…
  acme-supplier-margin:92:60: Error: unknown conversion %.
  "A script failure is deterministic: the same source on the same inputs
   fails the same way, so retrying it changes nothing."

manage_script command=patch  edits=[replace "%.2f%%" → "%s%%" with int(x * 100) / 100.0]
→ version 2   "Saved, and this version is what runs now"

manage_script command=run_draft
→ status failed   exports [supplier-margin-by-category csv 50 rows 3,378 bytes preview]
  acme-supplier-margin:103:16: Error in platform.export: rows must be a list
  of dicts, or a string body for a document format, got dict

manage_script command=patch  edits=[wrap the feed: rows = [{...}]]
→ version 3

manage_script command=run_draft
→ status succeeded   queries 3   steps 1807   duration 5,111 ms   run dpx_6e26…
  exports  supplier-margin-by-category  csv   50 rows   3,378 bytes   preview
           supplier-margin-feed         json   1 row   27,626 bytes   preview
  "Nothing was persisted. platform.export reported the shape of each
   output rather than writing it."

Every answer names its version and says what is now live, which is how the agent knows the patch it just made is the one that will run. A patch is anchored on text, never on a line number: an anchor that matches nothing or matches twice refuses the whole edit and writes nothing.

What validate reports

Validate is a static read of the source. It answers the question a reader has before running anything: what does this script reach? The report for the first script is short, and one field in it is a note rather than a list.

What validate reports, for this script

capabilities
platform.export, platform.query
Which of the four helpers the source uses. A script that only queries and exports can never refresh a dashboard or call another tool without a new version.
connections
[] with dynamic_connections true
The connections named literally in the source. This script takes its connection from a parameter, so validate says the list is incomplete instead of guessing.
destinations
portal
Where the outputs go. A destination Plexara does not have is reported here, before any query has run.
tools
[]
Every tool named in a platform.call. A computed tool name is reported as a gap rather than left out.
findings
null
Anything the static read objects to, with a correction for each: a credential-shaped literal, source that does not parse, a destination passed by position.

Validate executes nothing and stores nothing. It is how a reader learns what a script reaches without reading the Starlark, and the same report is one button on the script’s page in the portal.

The dry run and the account it keeps

A dry run executes the source for real, under your own identity, and persists nothing. It is the only way to try a change without making it live, because a save is immediately the version that runs.

What a dry run is

Executed as you
A dry run is your own session: your identity, your access, and nothing reachable through it that you could not already reach. A real run executes as the script, presenting the roles you held at the save.
Nothing persisted
Every export is serialized in its declared format to measure it, then discarded. The answer carries the shape and size a real run would write; no asset is versioned.
Tighter limits
A draft is capped at 5,000 rows per query and one minute. A platform run allows 20,000 rows per query, ten minutes, 16 outputs, and 100 MB per output. A source is at most 256 KB.
The account it keeps
Each dry run is recorded: who ran it, when, how it ended, what it printed, and the shape of its outputs. The account is keyed to the exact source that executed, so the version later saved from that source carries it, and a version with no account says so.
A script’s page in the portal after a dry run: the outcome, the log the script printed, and each output’s format, row count, and size, with nothing written.
A dry run on the script’s page executes what is on screen, as you, and reports each output’s shape instead of writing it. The same check the agent runs before every save.

Documenting the script

A script outlives the conversation that produced it, so its description is a document rather than a caption. The agent wrote this one at the save; the owner can rewrite it on the script’s page.

The four fields that say what a script is

Display name
The label every listing, page header, and search result prints. Supplier Quote Margin Review, here.
Description
A markdown document, not a caption: what the script produces, what each parameter means in the reader’s terms, what it assumes about the data, and what somebody re-reading it in six months needs. This one states the three queries, the two outputs, the cast rule, and that a month with no sales fails the run.
Category
One lowercase slug the listings filter on. Reuse an existing one rather than coining a near-duplicate; merchandising, here.
Tags
Free-form labels, up to twenty, for everything one category cannot carry.

All four are matched by search, with the parameter contract, so how a script is described decides whether anybody finds it. The owner edits them together from the About section’s Edit control on the script’s page, or the agent writes them at the save.

The About section of a script’s page in the portal, showing the rendered markdown description with its display name, category, and tags, and the Edit control that opens all four fields together.
The description renders as markdown on the script’s page, the way a knowledge page does. It is the document a colleague reads before deciding whether to run the script.

Editing it later

The script will change: a new column, a different threshold, a third output. The loop is the same the second time, on either surface.

A script’s page in the portal with the Starlark source in an editor, the Run and Dry run buttons above it, and the parameter form below.
The source on the script’s page, with Run and Dry run above the editor and the parameter form below. The owner edits here; the agent edits with an anchored patch. Both cross the same gate.
The version history of a script in the portal: each version with its author, when it was saved, and the roles a run of it presents.
Every save is a version with an author and the roles a run of it presents. The script in this lesson reached version 3 before its first real run and version 6 by the end of lesson 603.

The traps that fail a draft

Every one of these is a refusal with a named line and a stated fix, which is the point of running a draft before saving. The first two failed real drafts of this script; the last failed a real run in the next lesson.

The traps that fail a draft, and what the platform says

A DECIMAL arrives as text
Arithmetic on a cost column refuses, or a sum concatenates strings.
Pass every DECIMAL through float() before arithmetic: sum([float(r["total"]) for r in rows]).
A result hits the row cap
The query fails rather than returning a partial answer.
Aggregate in SQL or narrow the query. A partial result would silently change what the script computes.
SQL built by hand
One apostrophe in an upstream value breaks the statement, or appends one of its own.
Use :name placeholders and pass values in params; the platform quotes them by type. Compare a date as DATE :day.
A format the dialect does not have
Error: unknown conversion %. at the print line.
There is no %.2f. Use "%s" with int(x * 100) / 100.0, or "{}".format(x).
A JSON export given a dict
rows must be a list of dicts, or a string body for a document format, got dict.
Wrap the feed in a list. csv and json take rows; html and jsx take a string body.
One name written twice in a run
output "…" was already written to "portal" by this run.
One output name lands once per destination per run. Give the second write its own name, or branch so only one write happens.

Two of these failed real drafts in this lesson and one failed a real run in the next. All three refusals named the line and said what to do instead, which is what a dry run is for.

What the script does not yet do

At the end of this lesson there is a saved script that a call from any session can run. What it produces, where that lands, and how a dashboard can refresh itself from it are the next lesson.

Key terms

Five terms from the authoring loop: the two checks, the edit, the contract, and the language.

Key Terms

Validate
A static read of the source that reports the capabilities, connections, destinations, and tools a script reaches, with a correction for every finding. It executes nothing and stores nothing.
Dry runrun_draft
Executing the source as yourself, under draft limits, with nothing persisted. Each output is measured rather than written, and the run is recorded as an account keyed to the exact source.
Patch
An edit anchored on text, never on a line number. An anchor that matches nothing or matches more than once refuses the whole edit. Each patch that saves is a new version.
Parameter contract
The typed parameters a script declares: string, int, float, bool, date, enum, or connection, each with a description and, when optional, a default. Every run and every schedule binds values against it.
Starlark
The Python-shaped dialect managed scripts are written in, deliberately smaller: no imports, no clock, no network, no filesystem, no try, no while. Everything a script does is a platform call.