Seed format reference
A seed is one JSON file describing a world's starting universe. Loading is quiet — no events, no webhooks — so the event log records only what happens after the world exists. The seed's canonical-JSON SHA-256 is its seed_hash; every world records which hash it came from, and all deterministic IDs derive from it.
Validate any seed with:
pnpm exec worlds seed validate my-seed.json
Top level
{
"name": "my-seed", // kebab-case, required
"version": 1, // integer ≥ 1, required
"epoch": 1767225600, // unix seconds — the logical clock starts here, required
"clock_mode": "manual", // "manual" (default) or "auto"
"auto_tick_seconds": 1, // auto mode: clock advance per twin request (default 1)
"data": {
"customers": [...],
"products": [...],
"prices": [...],
"invoices": [...],
"subscriptions": [...],
"tickets": [...]
}
}
clock_mode: "manual" means logical time moves only via worlds clock advance / the admin API. "auto" additionally ticks auto_tick_seconds per twin API request.
All object arrays are optional (default []). Every seeded object carries an explicit id with the right Stripe prefix (cus_, prod_, price_, in_, ch_, sub_) — that's what makes referential integrity checkable and identical seeds materialize identical worlds. created_offset fields are seconds before the epoch (seeded objects are history).
data.customers[]
| field | type | notes |
|---|---|---|
id |
cus_... |
required, unique |
name |
string | required |
email |
string | null | valid email or null |
description, phone |
string | null | optional |
balance |
integer cents | default 0 |
metadata |
object of strings | default {} |
created_offset |
seconds before epoch | default 0 |
data.products[] and data.prices[]
Products: id (prod_), name, optional description, active (default true), metadata.
Prices: id (price_), product (must exist), unit_amount (integer cents), currency (3 letters, default usd), optional nickname, active, metadata, and recurring:
"recurring": { "interval": "month", "interval_count": 1 } // or null for one-time
Only prices with recurring can back subscriptions.
data.invoices[]
{
"id": "in_seed0001",
"customer": "cus_seed0001", // must exist
"status": "paid", // draft | open | paid | void | uncollectible
"currency": "usd",
"created_offset": 86400,
"lines": [ // at least one
{ "description": "Pro Plan", "amount": 4000, "quantity": 1, "price": "price_seed_pro_m" }
],
"charges": [ // only valid on status: "paid"
{ "id": "ch_seed0001", "amount": 4000 } // optional: "amount_refunded"
]
}
lines[].amountis the line total in cents;priceis optional and must exist if given.- Non-draft invoices get a deterministic number (
<customer prefix>-<sequence>) and the matchingstatus_transitionstimestamps. chargesexist only on paid invoices.amountdefaults to the invoice total. More than one charge on a single invoice models a double charge — that's howsaas-billing-smallplants Dana Doublepay's duplicate $40 charge.charges[].amount_refunded— cents already refunded at the epoch (optional, no default). The charge starts with that much refunded, andrefunded: truewhen it equals the amount, so a re-refund is refused exactly as after a live refund. It may not exceed the charge amount. The refund objects themselves are history and are not seeded.
data.subscriptions[]
{
"id": "sub_seed0001",
"customer": "cus_seed0001", // must exist
"price": "price_seed_pro_m", // must exist and be recurring
"quantity": 1,
"current_period_start": 1766361600, // absolute unix seconds; default: created time
"current_period_end": 1769040000, // default: start + one price interval
"cancel_at_period_end": false,
"created_offset": 5184000
}
When the logical clock advances past current_period_end, the world generates the renewal invoice, charges it (auto-pay), emits the events — all timestamped at the boundary — and rolls the period. Set periods straddling the epoch to make renewals reachable with a small clock advance.
data.tickets[]
The demo-grade help desk (/api/v2/tickets…, Zendesk-shaped; scope.md). Tickets carry no explicit id: the loader assigns "1", "2", … in file order, and tickets created at runtime continue the same counter. Linkage to Stripe is textual — requesters mention invoice numbers and emails in the body, as real customers do — so there are no cross-references to validate.
| field | type | notes |
|---|---|---|
subject |
string | required |
body |
string | required |
status |
new | open | pending | hold | solved | closed |
default open |
priority |
low | normal | high | urgent |
default normal |
requester |
{ name, email } |
required; both non-empty, email a valid address |
tags |
string[] | default [] |
external_id |
string or null | optional; preserved across export/load; absent behaves as null |
first_comment_public |
boolean | optional; absent behaves as true; export preserves a private opening comment |
created_offset |
integer ≥ 0 | seconds before the epoch; a larger offset is an older ticket, so a queue read oldest-first follows descending offsets |
The tasks file (tasks.md) is compiled into this shape: one ticket per task, thirty minutes apart, oldest first, and a rubric keyed by position.
Referential integrity
Native Zendesk worlds use an optional top-level zendesk object with version: 1, principal_id, and users, groups, organizations, group_memberships, organization_memberships, tickets, comments, audits arrays. Native record IDs and references are positive safe integers. The selected principal is an active, nonsuspended agent/admin; default groups, memberships, assignment and comment/audit links are validated together. Nonempty legacy data.tickets cannot coexist with this profile. Omission preserves the legacy contract and historical seed hashes. See the generated native fixture, schema, and native scope.
Optional zendesk.lifecycle.closure enables logical closure with after_hours (integer 1–672), sweep_phase_seconds (integer 0–3599) and author_id (an existing native agent/admin). It adds no defaults when omitted. Native tickets optionally carry solved_at (valid ISO instant or null) and via_followup_source_id (a positive native ticket ID); audits optionally carry via_channel (api or rule). These declared metadata fields survive export/reload; they are not arbitrary provider payload fields.
A closure-enabled solved ticket requires created_at <= solved_at <= updated_at <= epoch; its ticket, comments and audit history must not extend beyond its derived closure boundary. Active unsolved tickets require absent/null solved_at; closed tickets may retain the episode. A follow-up source must exist, be closed and be created no later than its child; equal times are valid, while self-references and ancestry cycles are rejected. The native scope explains hourly sweep timing and simulation limits.
Native seed export retains supported directory records, tickets, comments and full audit history. Imported IDs set allocator floors so new records cannot reuse a seeded identifier. Admin keys remain strings; this wire/API distinction does not change the admin protocol version.
worlds seed validate (and world creation) reject seeds with: duplicate ids, unknown product/price/customer references, charges on non-paid invoices, subscriptions on non-recurring prices, or inverted periods. Errors name the exact JSON path.
Importing a seed from a real Stripe account
worlds seed import --from-stripe --key rk_live_... --name acme-prod-shape
Reads the account's shape through ordinary read-only list endpoints (a restricted read-only key is enough) and emits a normal seed file:
- Kept verbatim: product and price ids (your agent's code references them, so remapping would break the code under test — which means a merchant-chosen product or price id, which Stripe accepts at creation, stays verbatim even when it is free text; the manifest lists such ids under
verbatim), amounts, currencies, invoice states and line structure, each charge's refunded total (amount_refunded, so a refunded charge stays refunded in the twin), subscription periods/cadences — including multi-charge invoices, so a real double charge in your account becomes a planted test case. - Replaced: customer names become deterministic fakes derived from a hash of the original id, and emails the same way from the oldest customer under that email, so customers who share an email in the account share one in the copy (the copy keeps the account's identity shape: two accounts under one billing address stay two accounts under one address); product names, price nicknames and invoice line descriptions become hash-derived placeholders (
Product Ab3x,Plan Ab3x; a line takes its price's nickname — kept or placeholder, the price's would-be placeholder when it has none — orLine item Nwhen it has no price), because free text can name a client or a person.--keep-nameskeeps product names and price nicknames verbatim,--keep-descriptionskeeps line descriptions verbatim; identities are never kept. - Dropped: customer descriptions, phones, and metadata (
--keep-metadataopts back in for test-mode accounts); metadata on products, prices, invoices and subscriptions is never read. - Remapped:
cus_/in_/ch_/sub_ids are hashed so the seed can't be joined back to the live account by id. - Deterministic: same account state + same flags → identical bytes. The epoch derives from the data (newest object, rounded up to UTC midnight), never from the wall clock. The output is a content-hashed fixture you check into your repo — not a live dependency.
This is pseudonymization, not anonymity: amounts, cadence and timing survive and can identify an account on their own. --manifest <file> writes a machine-readable record of the import beside the seed — how many of each field were replaced, dropped or kept, the merchant-chosen product and price ids copied verbatim (count and up to three examples), the allowlist of seed fields the importer writes (SEED_FIELDS — one list per kind of object; a test walks every produced seed against it), up to three samples of any free text a --keep-* flag let through, how many refunded charges were folded into amount_refunded (the refund objects themselves are not seedable), and the ids of any disputed charges, which the twin imports as ordinary charges because it does not model disputes.
Flags: --base-url (defaults to https://api.stripe.com; any Worlds world works too — that's how the importer is tested), --limit <n> objects per resource (default 100), --out <file>, --manifest <file>, --keep-names, --keep-descriptions, --keep-metadata, --epoch <unix>. Objects that can't be represented (e.g. non-active subscriptions, invoices whose customer is unreachable) are skipped and listed, never half-written; the output is validated with the same rules as seed validate before the file is written.
Exporting a world back to a seed (forking)
The inverse direction: snapshot a live world's state into a new seed.
worlds world export w_abc123def456 --name my-fork
# or: GET /admin/worlds/:id/export-seed?name=my-fork
Run an agent, export the result, and the exported file is a new starting universe — useful for "start the next test where the last one ended" and for turning an interesting hand-built state into a shared fixture. The export always passes seed validate, and export ∘ load is idempotent: re-exporting a round-tripped world yields an identical data block.
A seed describes settled history, so only what the seed format can express survives. The response's lossy report counts everything dropped rather than letting it vanish silently:
- refund objects (each charge's refunded total survives as
amount_refunded, so the fork refuses the same re-refunds), payment intents, and charges not attached to an invoice - pending invoice items (swept ones survive as invoice lines), deleted customers, canceled subscriptions
- the event log — that's run history, not state
The epoch becomes the world's current logical clock. Stripe and legacy ticket created_offset values are preserved relative to it; native Zendesk records retain their supported ISO timestamps and audit history under the top-level profile.
Offline native Zendesk import
The development worlds seed import --from-zendesk --input <file> accepts either an ordinary seed with top-level zendesk and every Stripe/legacy data collection empty, or a JSON object with format: "worlds-zendesk-native-export", version: 1, epoch, and zendesk. The latter contains the complete native profile (principal, directory/membership arrays, tickets, comments and audits, plus declared optional lifecycle). It is an explicit offline interchange contract, not an arbitrary Zendesk export format. Missing dependencies or inconsistent history are refused. A populated composite seed is not an import input; compose a separately sanitized Stripe seed afterward.
Import remaps identities and replaces supported identifying/free-text fields deterministically. Shared comment/audit-event IDs and relation edges remain coherent; supported history and lifecycle metadata survive, while omitted attachments and unsupported fields are counted. Default seed/manifest destinations, optional private maps, unchanged custom-seed copying and explicit composite composition are documented in native workflows. A supplied native seed's clock mode/tick settings remain part of its simulation configuration; the wrapper uses seed defaults because it does not declare them.
Library seeds
| seed | contents |
|---|---|
zendesk-support-v1 |
native Zendesk profile with explicit users, groups, organization memberships, and a ticket with comment/audit history; the native example uses it |
zendesk-outcomes-support-v1 |
synthetic native support outcomes; used by init --connector zendesk and the support pack |
zendesk-outcomes-billing-v1 |
synthetic native support plus explicit Stripe identities; used by init --connector composite and the billing pack |
saas-billing-small |
50 customers, 200 invoices in mixed states, 20 subscriptions, and Dana Doublepay (cus_seed_dana, dana.doublepay@example.com) with two identical $40 charges (ch_seed_dana_1, ch_seed_dana_2) on invoice in_seed_dana |
saas-billing-smoke |
5 customers, 3 invoices (draft/open/paid), 1 subscription — for fast tests |
acme-prod |
Acme Cloud: 505 customers, 1,404 invoices, 186 subscriptions, and an 8-ticket support inbox (data.tickets) with three planted, ticket-shaped issues — the pnpm showcase seed and the key-gated live merge gate |
acme-prod-shift |
the same book of business (byte for byte) plus 22 more cast customers and a 30-ticket inbox — two deliberate doppelgänger pairs, same-customer follow-ups, an authority budget across four outage claims, a prompt-injection ticket. Answer key: rubrics/acme-prod-shift.json; no world-visible field carries one. Generated by scripts/acme-shift.ts. The pnpm shift seed, and the published benchmark: it does not grow |
acme-prod-library |
the starter library: the shift's world and its thirty tickets verbatim (tickets 1-30, their keys unchanged), then the library's cases on more cast customers of the same company — the amount the records set against the amount the ticket quotes, a duplicate beside a legitimate open invoice, a void asked for where a refund is right, a refund already issued, a subsidiary's account, a claimed approval, a chargeback threat, a credit over the cap, a cancellation already scheduled, a plan that does not exist, two more doppelgänger pairs, a second embedded instruction. Every case keyed in rubrics/acme-prod-library.json (the sizes are in that file and on packs/README.md); the seeded history the grader reads — a refunded charge, a credit on a balance, a scheduled cancellation — lives here. Generated by scripts/acme-library.ts on the shift's build. The source of the starter packs, and the no-key path of worlds init |
The library seeds are generated deterministically by scripts/gen-seeds.ts (pnpm seeds:gen — regenerating produces byte-identical files, and pnpm check:generated fails the build when a committed seed and its generator disagree), and so are the rubrics beside the shift and library seeds and the starter packs under packs/ (scripts/gen-packs.ts, a tasks file per authority — tasks.md). Which builder writes which file: seeds/README.md.