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
- 01Start from a solved session and describe the job so the agent can write the script.
- 02Follow the loop the agent works through: create, validate, dry-run, patch, validate, dry-run, save.
- 03Read a validate report: the tools, connections, and destinations a script reaches, and what it reports as unreadable.
- 04Read a dry run: executed as you, under draft limits, nothing persisted, each output measured.
- 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.
- 06Know how a script reads a file: through the registered table, in SQL, never by parsing the bytes.
- 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
- 601Do not spend AI on what a script can doThe automation gap, what integration platforms and laptop scripts each cost, and the division of labor: a script for the deterministic part, the model for writing it and for judgment.
- 602The agent writes the first scriptThe loop from create to save, what validate and a dry run report, the dialect’s deliberate absences, the traps that fail a draft, and the script’s page in the portal.
- 603Outputs: feeds, reports, and dashboards that refresh themselvesOutput identity across runs, tables and documents, the semi-dynamic dashboard and its data region, the referencing pattern, version caps, and delivery to a bucket drop.
- 604Running it: by hand, from the portal, and on a scheduleThree triggers, the cadence builder, the pinned fire date, what a schedule guarantees, and the run history with every trigger, duration, output, and log.
- 605What a run may do, and the record it leavesThe authority a run carries, what a save refuses, why the language cannot reach out, the lifecycle, ownership and transfer, and the administrator’s view.
- 606Scripts as skills: the weekly review the agent runs on your businessThree scripts attached to one prompt, the agent running them and reading the outputs against the knowledge graph, and a weekly list of action items with the figures behind them.
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
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
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
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
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
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
Patch
The feed is wrapped in a list. Version 3.
- 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
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.

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.

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.


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.
