Sends a scripted scenario to a server so a handle means something outside your own checkout

Zpush uploads a .zas script to a server under a handle, and optionally creates a room to run it in. This is what lets you drive a scripted scenario through the UI of a server that does not have the repo.

npm run zpush --workspace @zooly/z-agent-srv -- --scenario KenjiVsSolstice --create-room

Why an upload step exists

A .zas you authored or edited is not automatically on a remote server. Uploading stores the script's source text in z_agent_mock_tape, keyed by handle, so any server can resolve it.

The checked-in fixtures under packages/z-agent/srv/scripts/test-scenario/ are a separate case. That folder is not copied into the Vercel serverless function, so at build time the files are inlined into the server bundle. That copy is what the Playground's Load an existing scenario dropdown uses on production — see the Zauthor tab. You still push after loading if you want the script stored under a handle (and to create a room from it). A script that is not one of those fixtures still has to be uploaded; there is nothing to inline.

A handle is looked up in three places, in order:

  1. The in-process registry, which Zreplay fills directly so it can run a script from any path with no upload at all.
  2. The database, which is how a handle you pushed works on a server that has no checkout. This is the path a scripted room uses after Push & create room.
  3. The scenario library — the folder on disk when the repo is checked out, otherwise the copy inlined at build time. Local convenience, and the fallback the Playground picker uses on Vercel.

Usage

OptionDefaultWhat it does
--scenario <name>Folder holding <name>.zas. Required unless --script is given
--script <path>A script file directly; needs --id for the handle
--id <handle>the scenario nameHandle to store under
--url <base>http://localhost:3004Server to push to, or Z_AGENT_MOCK_URL
--secret <token>Z_AGENT_MOCK_SECRETBearer token for the upload
--note <text>Stored alongside the script, e.g. why it was pushed
--create-roomoffAlso create a room and print its id and URL
--talent-account <q>Bind the talent seat when creating a room
--brand-account <q>Bind the brand seat
--real-roomoffThe created room is a real deal room, visible in both accounts' own dashboards, instead of an admin-only playground room. Requires both account flags
--guest-brandoffLeaves the brand seat an anonymous guest instead of binding --brand-account, so every push and any offer-minting effect is composed but held. Not supported with --real-room or --brand-account
--send-emailsoffLets the created room's wake turns send their real push emails too
--listoffList the handles a server already has

Pushing to a remote and creating a bound room in one go:

npm run zpush --workspace @zooly/z-agent-srv -- \
  --scenario KenjiVsSolstice \
  --url https://app.example.com \
  --create-room \
  --talent-account "Asi Meskin" \
  --brand-account "Elia Weiss123" \
  --note "checking the gray-zone path on staging"

Add --real-room to make that same room show up in both accounts' own /dashboard/conversations instead of only the admin playground — see Zreplay for what it makes visible (the conversation and any minted offer — still flagged isDemo and marked Demo in Offers & Deals).

Authentication

The upload routes accept either a bearer token or an admin session cookie. The token exists because a CLI has no cookie, especially when pointed at a remote server:

# on the server
Z_AGENT_MOCK_ENABLED=true
Z_AGENT_MOCK_SECRET=<a long random string>

# locally, so zpush picks it up without --secret
Z_AGENT_MOCK_SECRET=<the same string>

Two failure modes worth recognising:

  • 401 or 403 — the token is missing or does not match, and the admin cookie fallback found no session.
  • 404 — the server has Z_AGENT_MOCK_ENABLED unset, which hides these routes entirely rather than leaving an upload endpoint exposed where scripting was never wanted.

Validation happens on upload

The script is compiled before it is stored, so a malformed scenario is rejected with its line number rather than surfacing later as a tape miss halfway through a conversation:

Push to http://localhost:3004 failed (400):
  Line 47: "@plan push brand" has no matching "@wake brand"; the woken persona
  has no scripted line to deliver.

A successful push reports what the scenario will do, which is worth reading before you open the room:

Pushed "KenjiVsSolstice" to http://localhost:3004
  from:           .../test-scenario/KenjiVsSolstice/KenjiVsSolstice.zas
  turns:          7
  mints an offer: yes
  goals:          talent-setup=met, brand-offer=met
    offer-gen                1
    persona-brand            4
    persona-brand:wake       2
    persona-talent           3
    persona-talent:wake      3
    planner-guide             7
    planner-distil-mem       7

Uploading also clears the server's in-memory tape cache, so a room already running that handle picks up what you just pushed on its next turn instead of the version the process first compiled.

Why --send-emails works from a remote push

zreplay --send-emails sets an environment variable for its own short-lived process, which is fine because that process only ever drives one run. zpush has no such luxury — it is a separate process from whatever server it is pushing to, often on a different machine entirely, so there is no environment variable to set. --send-emails here instead asks the room-creation route to persist the choice on the room itself (mock_send_emails), which is also what the Playground's Zauthor tab uses — a setting that belongs to one room has to be stored somewhere that outlives any single request.

The local shortcut

If the server is on the same machine as your checkout, you never need HTTP. zreplay can store the script and create the room in one step:

npm run zreplay --workspace @zooly/z-agent-srv -- \
  --scenario KenjiVsSolstice --push --create-only \
  --talent-account "Asi Meskin" --brand-account "Elia Weiss123"

--push stores the source under its handle, and --create-only creates the room and stops without driving the script, leaving the conversation at its first line for you to drive by hand.

  room handle:   k91d04s43cbmhstxssfjv
  tape handle:   KenjiVsSolstice
  first line:    KenjiVsSolstice opens in the "brand" pane

Open it:
  http://localhost:3004/z-agent/playground?room=k91d04s43cbmhstxssfjv

The API directly

RouteWhat it does
GET /api/z-agent/mock-tapesLists stored handles
POST /api/z-agent/mock-tapes?id=<handle>Body is the .zas script verbatim; compiles, stores, returns the summary
POST /api/z-agent/mock-tapes/<handle>/roomsCreates a room running that handle; optional talentAccount / brandAccount in the JSON body
GET /api/z-agent/rooms/<id>/scriptThe scripted human lines, for a "next line" affordance
DELETE /api/z-agent/rooms/<id>/scriptRewinds the cursors so the room replays from the top