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[].amount is the line total in cents; price is optional and must exist if given.
  • Non-draft invoices get a deterministic number (<customer prefix>-<sequence>) and the matching status_transitions timestamps.
  • charges exist only on paid invoices. amount defaults to the invoice total. More than one charge on a single invoice models a double charge — that's how saas-billing-small plants 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, and refunded: true when 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 []
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

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/emails become deterministic fakes derived from a hash of the original id; 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 — or Line item N when it has no price), because free text can name a client or a person. --keep-names keeps product names and price nicknames verbatim, --keep-descriptions keeps line descriptions verbatim; identities are never kept.
  • Dropped: customer descriptions, phones, and metadata (--keep-metadata opts 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 and every object's created_offset is preserved relative to it.

Library seeds

seed contents
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

All five 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 two 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.