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 save the platform refused because the script had no tests yet and the test that failed before the save went through.
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. You read what the script will do; the platform holds the code to its tests.
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: a draft run that records its calls, a test that replays the recording, the tests run, then the save.
- 03Know what a save requires: the work in main(), the structural rules, and tests that pass, reach the code, and read every output.
- 04Read a validate report: the tools, connections, and destinations a script reaches, what it reports as unreadable, and what a save would say.
- 05Read a dry run: executed as you, under draft limits, nothing persisted unless you choose to write, each output measured, every call recorded.
- 06Know 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.
- 07Know how a script reads a file: through the registered table, in SQL, never by parsing the bytes.
- 08Declare typed parameters, document the script so search finds it, and read its page in the portal: the Flow, Source, and Tests tabs, Validate, Dry run, and 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 a recorded draft through tests to the save, what a save requires, 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.
- 607Events in: a webhook source, its table, and an automation that reads itCreating a source, how a sender proves itself and what it is told, the table that serves events from acknowledgement, compaction and retention, the Webhooks page, and an automation that reads only what is new.
- 608Results out: notification channels and signed delivery to other systemsChannel kinds and who may send to them, sending from a session and from an automation, the delivery history and the retry rule, the JSON envelope a system routes on, signed requests, and delivery to another deployment.
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
def main():
The work of a script is in main(), which the platform calls. The top level only declares: functions, constants written as literals, and docstrings. That is what lets a test call any part of the script on its own.
work at the top level
A call, a loop, or a read of run.params outside a function is refused on save, naming the line.
platform.query, platform.export, platform.publish_data, platform.call
The calls this script needs: read-only SQL with :name parameters, a named output, a data-region refresh, and any other Plexara tool by name. The platform also offers writes with bound values, notifications, saved state, checkpoints, progress, and a result handed back to the caller.
import
There is no module system. json, xml, and date are already here, and load() reaches only a shared platform library, at a version the script names.
json, xml, date, sum, and the builtins
json.encode and decode; xml.decode and encode; 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. testing and assert exist only inside a test.
try / except
Errors fail the run by design, so the failure is recorded rather than swallowed. Check first, call fail("why"), or ask for a failed call back as data with on_error = "return".
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, each opening with a one-sentence docstring, 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 short 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. The work is in main(), which the platform calls; everything above it only declares.
acme-supplier-margin-602, the saved version
SOLD = """
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
)
"""
BY_CATEGORY = SOLD + """
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,
...
FROM scratch.uploads.admin_supplier_price_sheet s
JOIN warehouse.public.products p ON p.sku = s.sku
...
"""
AT_RISK = SOLD + """..."""
COVERAGE = SOLD + """..."""
def month_window(day):
"""The first day of the month holding day, and the first day of the next."""
start = date.start_of_month(day)
return start, date.add_months(start, 1)
def weighted_change(rows):
"""The cost change across every quoted category, weighted by units sold, in percent."""
record = sum([float(r["cost_of_record"]) for r in rows])
quoted = sum([float(r["quoted_cost"]) for r in rows])
return int(10000.0 * (quoted - record) / record) / 100.0
def main():
"""Exports the month's cost change by category and a feed with the at-risk SKUs and coverage."""
start, end = month_window(run.params["month"])
window = {"start": start, "end": end}
conn = run.params["conn"]
categories = platform.query(connection = conn, sql = BY_CATEGORY, params = window)["rows"]
if len(categories) == 0:
fail("no sales joined to the price sheet for %s; nothing to publish" % start)
at_risk = platform.query(connection = conn, sql = AT_RISK, params = window)["rows"]
coverage = platform.query(connection = conn, sql = COVERAGE, params = window)["rows"][0]
change = weighted_change(categories)
print("categories quoted: %d, weighted cost change %s%%" % (len(categories), change))
print("coverage: %s of %s quoted" % (coverage["quoted"], coverage["products_in_range"]))
platform.export(name = "supplier-margin-by-category", rows = categories, format = "csv")
platform.export(
name = "supplier-margin-feed",
rows = [{
"as_of": run.fire_time,
"month": start,
"weighted_cost_change_pct": change,
"coverage": coverage,
"at_risk": at_risk,
}],
format = "json",
)Abridged from the real source, which writes the three statements out in full and carries its four tests below main(). 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, and two outputs under stable names. The SQL is held in constants and the arithmetic in small functions with a docstring each, so a test can check each one alone and the flow diagram can label each step. The agent wrote it; the person who asked for 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, list, date range, or connection, and every surface that asks for a value renders the control the type deserves: a list of enum values as checkboxes, a range as two dates. 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: draft, test, save
The agent does not write the script and hand it over. It runs the source as a draft against real data, which records every call the draft made, writes tests that replay that recording, runs the tests until they pass, and only then saves. A save is refused until it does. On the demo tenant that took one refused save and one failing test. 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
Draft run
The agent runs the source before anything is saved, under the name it will have, as itself, with nothing persisted. Three queries, 1,517 steps, 7.8 seconds, both outputs measured. The draft also records every call it made and the answer it got, and hands back the recording’s id.
- 2
Save, refused
A first attempt to create the script is refused and nothing is stored: the source has no tests, and a script is saved only with at least one test that replays a recorded run and asserts on what main() produced.
- 3
Tests written
Four tests beside main(): two check the month window and the weighted change on rows written into the test, one replays the recording and compares the exported rows, and one declares an empty answer for the warehouse and checks that the run fails with its reason.
- 4
Tests, one fails
The test run reports three passing and one failing, at its line: the recorded fire time comes back to the second, not the nanosecond the test expected. It also lists the feed’s columns no test has read yet.
- 5
Tests pass
One expected value corrected. Four tests pass, every statement of the script is reached, and every column of both outputs is read by an assertion.
- 6
Saved
The create goes through. The save ran the tests again and kept their report on version 1, and the answer says the script now runs under the access its author held at the save.
- 7
The saved version runs
Version 1 is what run_script executes and what a schedule fires. The first real run, for December, wrote both outputs as versioned assets in about ten seconds.
A refused save and a failing test cost nothing: a draft persists nothing, a test never reaches the warehouse, and a refused save stores nothing. The loop was run under its own name, acme-supplier-margin-602, so the script the rest of the series runs, acme-supplier-margin, was left as it is. That script was first saved before saves required tests, and keeps running as it was; its next version will meet every rule above.
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=run_draft name=acme-supplier-margin-602
params=[month: date (default 2025-11-01), conn: connection (default acme)]
args={month: 2025-11-01}
→ status succeeded saved false queries 3 steps 1517 duration 7,844 ms
exports supplier-margin-by-category csv 50 rows 3,002 bytes preview
supplier-margin-feed json 1 row 4,813 bytes preview
log categories quoted: 50, weighted cost change 2.37%
coverage: 400 of 450 quoted
recording dpx_a4b2…
"Nothing was persisted. platform.export reported the shape of each output
rather than writing it."
manage_script command=create name=acme-supplier-margin-602 (no tests yet)
→ status invalid
"the source has no tests, so it was not saved: a script is saved with at
least one test_* function that replays a recorded run
(testing.replay("<run id>"), the id run_draft returns) and asserts on what
main() produced"
manage_script command=test (four tests added)
→ ok false passed 3 failed 1
test_a_recorded_month line 118
assert.eq: got "2026-10-07T18:37:14Z", want "2026-10-07T18:37:14.259852614Z"
unread output "supplier-margin-feed" column "weighted_cost_change_pct"
output "supplier-margin-feed" column "coverage"
output "supplier-margin-feed" column "at_risk"
manage_script command=test (expected fire time corrected)
→ ok true passed 4 failed 0 coverage 26 of 26 statements unread []
manage_script command=create name=acme-supplier-margin-602
display_name="Supplier Quote Margin Review (lesson 602)" category=merchandising
→ status created version 1 tests passed 4
"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."
run_script name=acme-supplier-margin-602 args={month: 2025-12-01}
→ status succeeded version 1 trigger tool duration 10,068 ms
outputs supplier-margin-by-category csv 50 rows asset version 1
supplier-margin-feed json 1 row asset version 1Every refusal says what to do next, which is how the agent works the loop without anyone reading the code. The test that replays the recording answers every query from what the draft got, so it runs in milliseconds and reaches no upstream. A later edit is made with an anchored patch, never a line number: an anchor that matches nothing or matches twice refuses the whole edit and writes nothing.
The tests a save requires
A test is part of the script, saved and versioned with it. Its job is to show that the script does what it says with the data it was given, so the platform checks the tests themselves as well as running them.
Two of the four tests the script was saved with
def test_a_recorded_month():
"""November exports every category and a feed with the at-risk SKUs and coverage."""
testing.replay("dpx_a4b2…")
main()
out = testing.outputs()
assert.eq(out.exports[0].rows[0], {
"category": "Flowers & Plants",
"department": "Garden",
"quoted_products": 5,
"units": 296,
"cost_of_record": "2956.83",
"quoted_cost": "3390.92",
"cost_change_pct": "14.700",
"margin_projected_pct": "19.600",
})
feed = out.exports[1].rows[0]
assert.eq(feed["month"], "2025-11-01")
assert.eq(feed["as_of"], "2026-10-07T18:37:14Z")
assert.eq(feed["weighted_cost_change_pct"], 2.37)
assert.eq(feed["coverage"], {"products_in_range": 450, "quoted": 400})
assert.eq(feed["at_risk"][0]["sku"], ...)
def test_a_month_with_no_sales_fails():
"""A month with no sales joined to the sheet fails the run and publishes nothing."""
testing.set_run(params = {"month": "2023-01-01", "conn": "acme"})
testing.answer("trino_query", {"connection": "acme"}, {"columns": [], "rows": [], "row_count": 0})
assert.contains(assert.fails(main), "no sales joined to the price sheet for 2023-01-01")The first test replays the draft (its recording id shortened here): every query is answered from the recording, so it checks the real November numbers without touching the warehouse. The second reaches the branch no draft of November takes by declaring the answer a query gets. testing and assert exist only inside a test, and a schedule never runs one.
What every save checks
- At least one test, every test passing
- A test is a top-level function whose name starts with test_. It names a recording once, calls main() or any other function, and asserts on what came out. A test that asserts nothing is refused.
- The tests reach the code
- At least 80 percent of the script’s statements are reached by some test. A refusal names the lines no test reached, which is usually a branch only a declared answer can take, like the empty month above.
- The tests read the data
- The platform drops the last row of each replayed result and changes the first row’s values, then runs the test again. A test that still passes is not reading what the script made of the data, and is refused.
- Every output is read
- Together the tests read each column of each export, the state a run saves, each notification, and any result handed back. A save that leaves one unread is refused naming it, as the first test run above did.
- The structural rules hold
- Work in main(), a docstring on every function, short functions with few paths through them, no unused names, SQL bound with params rather than built from values, and no query or export inside a loop over rows.
These checks are written for the agent. A person who asks for an automation never reads a finding. What they do see comes with a later version: when a change would alter what the automation produces or reaches, the save waits for their agreement to a plain-language summary, which lesson 605 covers.
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. It is a finding for the author, not a reason to refuse the save, because refusing it would block the edit that fixes it.
- 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 structural rule broken, a destination this deployment does not have.
- tests
- passed 4, 26 of 26 statements, unread []
- Validate runs the tests too, so it answers what a save would say without saving. On a new version of a script that has run, it also lists what the version would change in what the automation produces or reaches.
Validate executes nothing but the tests 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 unless you choose to write. It is the only way to try a change without making it live, because a save is immediately the version that runs, and the recording it leaves is what the tests replay.
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, and a write reached through another tool is refused and named rather than made.
- Every call recorded
- The draft records each call it made and the answer it got, with the parameters it started from, and returns the recording’s id. That recording is what a test replays.
- Writing for real, when chosen
- A pipeline whose next step reads what the last one created cannot be rehearsed without the create, so a draft can be told to write. It then writes as you and lists every call that persisted. The choice applies to that one draft only.
- Tighter limits
- A draft is capped at 5,000 rows per query and one minute. A platform run allows 20,000 rows per query, fifteen minutes, 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, with one addition: a version that changes what the automation does waits for the agreement of the person it runs for.


The traps that fail a draft
Every one of these is a refusal with a named line or output and a stated fix, which is the point of running a draft and the tests before saving.
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.
- A save with no tests
- the source has no tests, so it was not saved
- Draft the script, write a test_ function that replays the draft’s recording and asserts on what main() produced, run the tests, then save.
- An output no test reads
- output "supplier-margin-feed" column "coverage" is never asserted on
- Assert on that column in some test. Comparing a whole row reads every column in it.
- 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.
The first four are dialect and data traps; when this script was first written, two of them failed its drafts and one failed a real run in the next lesson. The two save refusals happened in this lesson’s loop. Every refusal named the line or the output and said what to do instead.
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
Six terms from the authoring loop: the two checks, the tests, 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, and runs the tests to say what a save would say. It stores nothing.
- Dry runrun_draft
- Executing the source as yourself, under draft limits, with nothing persisted unless you choose to write. Each output is measured rather than written, every call is recorded for a test to replay, and the run is kept as an account keyed to the exact source.
- Test
- A test_ function saved beside main(). It replays a recorded draft or declares the answers calls get, runs the script, and asserts on what it produced. Every save runs the tests and keeps their report on the version.
- 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, list, date range, 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. The work is in main(), and everything a script does is a platform call.
