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.

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.

Why it exists

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.

What it needs

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.

Usage

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."
OptionDefaultWhat it does
--scenario <name>requiredFolder holding talent.md and brand.md
--outcome <what>metmet closes and mints an offer, abandoned dies, open is still running when the script ends
--turns <n>7Roughly how many exchanges to write across both conversations
--note <text>Extra direction: the angle, the sticking point, who concedes
--model <id>anthropic/claude-fable-5Gateway model to draft with
--out <path><scenario>/<scenario>.zasWhere to write
--forceoffOverwrite an existing script
--printoffWrite nothing, print the draft

What a clean run looks like

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.

Nothing invalid reaches disk

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:

  • has a @plan push with no matching @wake in the same turn, or the reverse
  • pushes to the conversation the turn is already in, where the push is skipped
  • names a participant other than talent or brand
  • flips a goal the room does not declare, or uses a status code does not know
  • writes an @offer without a goal in the same turn reaching met with a handler attached, so Offer-Gen would never be called
  • writes a @plan set value that is not valid JSON
  • leaves a turn without either a human message or an agent reply

Zauthor 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.

Spacing is applied, not requested

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

The format it writes

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.

## 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
  • a plain paragraph is the agent's scripted reply
  • @ lines are beats: what the planner does on receiving that message

Beats 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.

The beats

BeatEffect
@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
@offerCreates 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.

After drafting

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.