> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pome.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Build your own world

> Seed a digital twin with your own repositories, threads or customers — one generated file that boots a local twin and a hosted sandbox alike.

Every twin boots with a world already in it: `acme/api` and one open bug on
GitHub, a `#general` channel on Slack, an empty ledger on Stripe. That world is
there so the first command works. It is not there because it looks like yours.

A **seed** is how you replace it. Write your repositories, your threads, your
customers, and the twin serves them to your agent as though they were real —
same routes, same status codes, same recorded tape. This page is the whole job,
from generating the file to proving every field of it landed.

## A seed replaces the world. It does not merge into it.

This is the single most costly thing to get wrong, so it comes first.

<Warning>
  A seed is the twin's **entire** starting state, not a patch on top of the
  default one. Seed a GitHub world and `acme/api` is gone — anything your agent
  needs to find has to be in the file you wrote.
</Warning>

Measured on a local twin booted from the file further down this page:

```text theme={"dark"}
$ API=$POME_GITHUB_REST_URL
$ AUTH="Authorization: Bearer $POME_AUTH_TOKEN"

$ curl -so /dev/null -w '%{http_code}\n' -H "$AUTH" \
    "$API/repos/northwind/payments"
200
$ curl -so /dev/null -w '%{http_code}\n' -H "$AUTH" \
    "$API/repos/acme/api"
404
```

There is no merge mode and no partial seed. A seed that omits `labels` boots a
repository with no labels, not a repository with ours.

## Generate the file; do not write it

```bash theme={"dark"}
pome twin new-seed github --out world.seed.json
```

[`pome twin new-seed`](/docs/cli/twin#twin-new-seed) prints the twin's own
starting state, computed from the twin's declared world and normalised through
the same `parseSeed` the twin runs at boot. Whatever it prints, the twin loads.
Start from that and edit; do not compose one from the field names in a reference
table.

This is not a style preference. On 2026-08-26 three of the five seed examples
published on this site could not boot the twin they were written for —
`repositories.0.owner` missing on GitHub, `primaryMailbox` typed as a string on
Gmail, `issues.0.assignee` null on Linear. Every one of them was correct the day
it merged, and each was hand-typed. Copying one produced a `503 Failed to spawn
twin pod` twelve seconds later. Every seed block on this site is now generated
and re-parsed in CI against its own twin.

The generated file is a starting point with real bulk to it — with
`@pome-sh/cli@0.42.1`: 82 lines for GitHub, 89 for Slack, 136 for Gmail, 270
for Linear, 14 for Stripe's deliberately empty ledger. Cutting it down is the
normal next step.

## Seed file

Here is the GitHub file above, cut to one organisation, one repository and the
one issue a triage task needs. It is what `pome twin new-seed github` printed,
with the sample world's names and rows replaced:

```json theme={"dark"}
{
  "users": [
    { "login": "northwind", "type": "Organization", "name": "Northwind" },
    { "login": "dana", "type": "User", "name": "Dana Okafor" },
    { "login": "pome-agent", "type": "User", "name": "Pome Agent" }
  ],
  "repositories": [
    {
      "owner": "northwind",
      "name": "payments",
      "description": "Billing service for the Northwind storefront.",
      "private": false,
      "default_branch": "main",
      "collaborators": ["dana", "pome-agent"],
      "labels": [
        { "name": "bug", "color": "d73a4a", "description": "Something is not working" },
        { "name": "billing", "color": "0e8a16", "description": "Touches money" }
      ],
      "issues": [
        {
          "number": 1,
          "title": "Partial refunds replay the webhook forever",
          "body": "Every partial refund re-enqueues its webhook until the worker backs up.",
          "state": "open",
          "labels": [],
          "assignees": [],
          "comments": []
        }
      ],
      "pull_requests": []
    }
  ]
}
```

**One twin is flat; two or more is the per-twin envelope.** The file above is
GitHub's world with no wrapper around it — which also means it says nothing
about which twin it is for, and that is why both `--seed` doors below take the
twin's name as well. Add a second twin and the file grows the envelope, one key
each:

```jsonc theme={"dark"}
{
  "github": { /* the object above */ },
  "slack":  { /* what `pome twin new-seed slack` printed, edited the same way */ }
}
```

Write one file covering both in a single command:

```bash theme={"dark"}
pome twin new-seed github slack --out world.seed.json
```

Seeding two twins is not the same job as running them side by side against one
agent — that half has its own guide,
[Two twins, one sandbox](/docs/multi-twin-sandboxes).

JSON or YAML — both doors parse either. A `_meta` block, which
[`pome compile-seeds`](/docs/cli/compile-seeds) writes into a task's sidecar, is
dropped before the twin sees it.

Each twin's own generated block, and the field names behind it, live on that
twin's page: [github](/docs/twins/github#task-seed-shape),
[stripe](/docs/twins/stripe#task-seed-shape),
[slack](/docs/twins/slack#task-seed-shape),
[gmail](/docs/twins/gmail#task-seed-shape),
[linear](/docs/twins/linear#task-seed-shape).

## One file, both doors

The same `world.seed.json` boots a twin on your laptop and a hosted sandbox. No
conversion, no second copy.

<Tabs>
  <Tab title="Local — no account">
    ```bash theme={"dark"}
    pome twin start github --seed world.seed.json
    ```

    Name the twin. A one-twin file is flat, so it cannot say which twin it is for,
    and a file covering several does not say which of them to boot; only a
    hand-written envelope naming exactly one twin makes the argument optional. The
    command prints the twin's URL and a bearer, and the boot line names the file:

    ```text theme={"dark"}
    Pome github twin listening at http://127.0.0.1:3333/s/standalone
    Seed: world.seed.json (replaces the github twin's default).
    POME_GITHUB_REST_URL=http://127.0.0.1:3333/s/standalone
    POME_GITHUB_MCP_URL=http://127.0.0.1:3333/s/standalone/mcp
    POME_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIs…
    Health check (no auth): curl http://127.0.0.1:3333/healthz
    Ctrl-C to stop.
    ```
  </Tab>

  <Tab title="Hosted">
    ```bash theme={"dark"}
    pome sandbox create --twin github --seed world.seed.json
    ```

    Same file, and `--twin` for the same reason. Repeat the flag for each twin the
    file covers. The CLI sends the shape the API wants — see
    [`pome sandbox`](/docs/cli/sandbox) for the flags, and
    [Sandboxes](/docs/sandboxes) for what one is and how long it lives.
  </Tab>

  <Tab title="REST">
    ```bash theme={"dark"}
    curl -sX POST https://api.pome.sh/v1/sandboxes \
      -H "Authorization: Bearer $POME_API_KEY" -H 'Content-Type: application/json' \
      -d '{"twins":["github"],"seed":{"users":[…],"repositories":[…]}}'
    ```

    Same rule on the wire: **one twin takes the flat object, more than one takes the
    per-twin envelope**, and the control plane decides from `twins` alone rather than
    from the seed's contents. So a seed file drops straight into `seed` with nothing
    to unwrap; what the request adds is the `twins` array, which is where the names
    come from here. See [Seeds](/docs/api/endpoints#seeds).
  </Tab>
</Tabs>

<Note>
  The seed is parsed **before** a hosted sandbox is provisioned, so an unbootable
  world costs a `422` in well under a second rather than a spawn that fails.
  [`POST /v1/seeds/validate`](/docs/api/endpoints#post-v1seedsvalidate) runs the
  same check and provisions nothing at all.
</Note>

## Check that every field landed

A twin that boots is not proof that your whole world arrived — until
2026-08-29 a typo'd field name could be silently dropped on three of the five
twins. No longer, on your machine. Measured 2026-08-30 against
`@pome-sh/cli@0.42.1`, a GitHub seed carrying `isuses` instead of `issues`
refuses to boot, naming the key and its path:

```text theme={"dark"}
$ pome twin start github --seed broken.seed.json
--seed broken.seed.json is not a seed this twin can boot: [
  {
    "code": "unrecognized_keys",
    "keys": [
      "isuses"
    ],
    "path": [
      "repositories",
      0
    ],
    "message": "Unrecognized key: \"isuses\""
  }
]
```

All five twins refuse the same way at `twin start --seed` since
[F-1689](https://linear.app/pome-sh/issue/F-1689) landed. A wrong *type* was
always refused everywhere — `expected string, received undefined` — so a local
boot now vouches for every field name and type in your file.

<Warning>
  **The hosted door has not caught up yet.** `POST /v1/seeds/validate` and
  sandbox create still run the previously pinned parsers
  (`@pome-sh/sandbox-domains@0.2.8`), on which `github`, `slack` and `stripe`
  accept an unrecognised key and silently drop it — the `isuses` seed above
  answers `valid: true` and boots a hosted world with no issues in it. Until the
  pin moves, two habits close that gap: **boot the seed locally first**, where
  the refusal above catches the typo for free, and **read your hosted
  collections back** — an empty array where you wrote rows is a dropped key.
  [F-1775](https://linear.app/pome-sh/issue/F-1775) tracks the pin move.
</Warning>

## Seed file, or task sidecar

One file, two destinations. `pome twin new-seed <twin>` writes it either way, and
the shape follows the twin count rather than where it is going.

|               | Seed file                                              | Task sidecar                                |
| ------------- | ------------------------------------------------------ | ------------------------------------------- |
| Named         | anything — `world.seed.json`                           | `<task>.seed.json`, beside the task's `.md` |
| Read by       | `pome twin start --seed`, `pome sandbox create --seed` | `pome run`, and hosted runs of that task    |
| Twin named by | you, on the command line                               | the task's `## Config` block                |

Both are flat for one twin and the per-twin envelope from two up, so a file
written for one destination works at the other. What differs is only the last
row: a sidecar has the task's `## Config` beside it, while a seed file travels
alone and so has to be handed the name. Replace-not-merge applies to both, and
both accept JSON or YAML.

A task is not required to seed a world. A hosted sandbox created with `twins`
and `seed` and no task records no task at all, which is what a
bring-your-own-eval harness wants: you drive the agent, and you score the tape
from the twin's own `/_pome/events`.

## Next

<CardGroup cols={2}>
  <Card title="Write a task" icon="file-pen" href="/docs/authoring-tasks">
    Turn the world you just built into a graded exam — criteria, `[code]` checks,
    and why a criterion the seed already satisfies leaves the denominator.
  </Card>

  <Card title="pome twin" icon="terminal" href="/docs/cli/twin">
    Every flag on `twin new-seed`, `twin start` and `twin status`.
  </Card>

  <Card title="Seeds over REST" icon="webhook" href="/docs/api/endpoints#seeds">
    The wire shape, `POST /v1/seeds/validate`, and the error vocabulary a client
    branches on.
  </Card>

  <Card title="Digital twins" icon="boxes" href="/docs/twins/coverage">
    Each twin's generated starting state, its declared checks, and what it serves.
  </Card>
</CardGroup>
