Drafts a scripted test scenario from its persona briefs, and refuses to write one that would not exercise what it claims to
Zauthor writes scripted test scenarios for Z-Agent. You give it a scenario's
persona briefs and the outcome you want, and it drafts a .zas script: a
complete two-sided negotiation, both conversations, in the order they happened.
That file is then replayed by Zreplay against the real pipeline with every agent reading its lines from the script instead of calling a model — a full negotiation in about two seconds, for zero tokens.
Zauthor is the only part of this system that spends tokens, and it spends them once. The draft is committed and read from then on. A model in the replay loop would put back both the cost and the nondeterminism that scripted scenarios exist to remove.
The same generate → push → create-room flow is also available with no
terminal, from the Playground's Zauthor tab.
The tab starts from a deal sketch and writes talent.md / brand.md
first; this CLI still reads those briefs from a scenario folder on disk.
A scenario file is not hard to write, but it is tedious and easy to get subtly wrong: every planner push needs its matching wake line, an offer only fires alongside a goal flip, and both humans have to stay in character for eight or nine turns. Zauthor does the tedious part, and the compiler catches the subtle part before anything reaches disk.
A scenario folder under packages/z-agent/srv/scripts/test-scenario/<name>/
containing the two persona briefs the live simulator already uses:
scripts/test-scenario/MaraVsNimbus/
talent.md the person being represented
brand.md the company approaching them
Those briefs are what the drafted humans are written from, so a sharper brief
produces a sharper scenario. Zauthor writes MaraVsNimbus.zas alongside them.
The Playground's Zauthor tab can load these
folders on a Vercel deploy as well as locally. The folder is not traced into
the serverless function, so a build inlines it
(npm run bundle-scenarios --workspace @zooly/z-agent-srv). Run that after
adding a scenario, or rely on the zooly-app production build, which runs
it automatically. A unit test fails if the generated copy drifts from the
folder.
npm run zauthor --workspace @zooly/z-agent-srv -- --scenario MaraVsNimbus
With direction, which is usually worth giving:
npm run zauthor --workspace @zooly/z-agent-srv -- \
--scenario MaraVsSpinRush \
--outcome abandoned \
--turns 6 \
--note "Gambling is on her exclusion list. The brand should raise the money twice before accepting the no."
| Option | Default | What it does |
|---|---|---|
--scenario <name> | required | Folder holding talent.md and brand.md |
--outcome <what> | met | met closes and mints an offer, abandoned dies, open is still running when the script ends |
--turns <n> | 7 | Roughly how many exchanges to write across both conversations |
--note <text> | — | Extra direction: the angle, the sticking point, who concedes |
--model <id> | anthropic/claude-fable-5 | Gateway model to draft with |
--out <path> | <scenario>/<scenario>.zas | Where to write |
--force | off | Overwrite an existing script |
--print | off | Write nothing, print the draft |
Drafting MaraVsNimbus (met) with anthropic/claude-fable-5...
Compiles clean.
turns: 8
brand-offer ends: met
mints an offer: yes
offer-gen 1
persona-brand 4
persona-brand:wake 2
persona-talent 4
persona-talent:wake 3
planner-guide 8
planner-distil-mem 8
tokens: 2834 in, 5358 out
Wrote .../test-scenario/MaraVsNimbus/MaraVsNimbus.zas
The per-agent counts are the compiled tape: how many scripted lines each agent
will be served. planner-guide and planner-distil-mem matching the turn count is
expected — an auto-planned room runs both stages on every spoken turn, so a turn
that changes nothing still gets a no-op entry for each stage.
Every draft is compiled before it is written, and a draft that does not compile is printed to the terminal and not saved. This is the part that makes generated fixtures safe to trust: a scenario that quietly compiled to an empty update list would produce a green replay that tested nothing, which is worse than a failure.
The compiler rejects a draft that:
@plan push with no matching @wake in the same turn, or the reversetalent or brand@offer without a goal in the same turn reaching met with a
handler attached, so Offer-Gen would never be called@plan set value that is not valid JSONZauthor also checks the draft against the outcome you asked for: --outcome met
is rejected if the draft mints no offer, and --outcome abandoned is rejected if
it mints one. An abandoned negotiation creating an offer record would be a real
bug, and a fixture that did it would hide one.
When a draft is rejected, the usual fix is to edit the printed draft by hand
rather than pay for another one — it is normally a single missing @wake.
Every draft goes through a formatter before it is written: one blank line after the human's message and after every beat. Beats are long, and a run of them with no separation reads as one block of noise.
The prompt asks for this too, but a drafting model will space the first turn
correctly and drift by the third, so the guarantee comes from code. The formatter
only ever inserts blank lines, and never inside a human message — that text is
taken verbatim — nor between @offer and its indented fields, which the parser
stops collecting at the first blank line.
For scripts written or edited by hand, the same pass is available on its own:
npm run zformat --workspace @zooly/z-agent-srv # every scenario
npm run zformat --workspace @zooly/z-agent-srv -- --check # report only, exit 1 if any need it
Worth knowing enough of to read and edit the output. A script is one file holding both conversations, and position in the file is the global order.
.zas files get syntax highlighting from the grammar in tools/vscode-zas.
Run ./tools/vscode-zas/install.sh once and reload the window. It marks the
three things the compiler rejects outright — an unknown goal status, an unknown
@ beat, and an unknown @offer field — so those show up red before you run
anything.
## brand
/*
Jordan Wells, IronPeak Recovery. We want Mike Tyson for our fall launch —
one hero video, three Instagram posts. We're thinking around $120,000.
*/
@plan push talent: A recovery-gear brand has made contact. Get his terms first.
@wake talent: A company called IronPeak wants you for a fall campaign. They
opened at $120,000. Before I answer them I need your terms.
Thanks Jordan, that's noted. Before I respond on money I need to confirm his
terms with him.
## talent / ## brand switches which conversation the following turns are in/* ... */ is the human's message, taken verbatim so it can be pasted
straight into the UI@ lines are beats: what the planner does on receiving that messageBeats sit between the human's message and the agent's reply because that is the real execution order — the planner runs first, then the persona answers with the freshly planned memory. A wake therefore lands in the other conversation before the current one's reply streams.
| Beat | Effect |
|---|---|
@plan goal <key> = <status> | Flips a goal. Keys: talent-setup, brand-offer. Statuses: open, met, blocked, abandoned |
@plan push <who>: <text> | Queues a push to another participant (handled by PlannerDistilMemAg) |
@wake <who>: <text> | What that agent actually says. Always paired with a push in the same turn |
@plan guide <who>: <text> | An instruction for a participant's agent (active participant routes to planner-guide; others to planner-distil-mem) |
@plan set <path> = <json> | Writes a memory branch via PlannerDistilMemAg. plannerNotes.* is private and never shown to an agent |
@offer | Creates the offer record. Only valid alongside @plan goal brand-offer = met |
The @offer block takes whole currency units, not cents — the compiler owns the
conversion, because a fixture writing cents by hand is one zero away from
asserting the wrong amount and looking correct:
@offer
amount: 28,000 USD
brand: Nimbus Sleep Labs
campaign: Two Instagram feed posts and one short YouTube segment on recovery.
usage: organic social — 6 months from first publication
why: Nimbus met every one of her terms and she accepted explicitly in conversation.
amount, campaign and why are required; brand, usage, script and
sharing are optional.
Read the draft — it is a fixture, and it should read like a negotiation you recognise. Then run it:
npm run zreplay --workspace @zooly/z-agent-srv -- --scenario MaraVsNimbus
Zreplay asserts the outcome the beats add up to, so a scenario that drifts from what its script says will fail rather than pass quietly.
Zauthor needs AI gateway credentials from apps/zooly-app/.env.local, and it
does spend tokens. Zreplay needs none of its own for a script that covers the
whole run — a scripted room only reaches a provider if the conversation outruns
its script, in which case Zreplay falls back to a real
model for that one call rather than failing the turn.