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 []
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 — 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. 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.