> ## 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.

# Two twins, one sandbox

> Run the GitHub and Slack digital twins in a single sandbox: one id, one bearer, one clock. Watch an agent triage a bug in one and announce it in the other — and see exactly which part of that the platform did not do for you.

<Info>
  <Icon icon="clock" /> **About 5 minutes.** This one needs a Pome account: it is
  the page about the thing that only exists in the cloud. Everything else in
  [Get started](/run-a-twin) runs on your own machine with no account, and
  you can do that first.
</Info>

Real work crosses systems. An agent notices something in GitHub and says so in
Slack; it takes a payment in Stripe and files the receipt in Gmail. A twin of one
service tests half of that.

So a **sandbox** holds up to three **digital twins** at once. This page starts one
with GitHub and Slack in it, lets an agent work across both, and then reads back
what each twin recorded.

## What you are about to see, both halves

**One sandbox is one thing you hold.** It has a single id, a single bearer token
that opens every twin in it, a single 30-minute clock, and it is one line on the
bill no matter how many twins are inside.

**The twins inside it share nothing else.** Each keeps its own state, and each
writes its own tape — the record of the calls it served. The GitHub write does
not appear on Slack's tape, and Slack's does not appear on GitHub's.

Which leaves the interesting question, and it is the one this page exists to
answer: if the platform moves nothing between the two twins, **what carried the
issue number from GitHub into the Slack message?**

<Note>
  **Your agent did.** Pome gives the two twins one address book, one credential and
  one lifetime. It does not copy a fact out of one and into the other, and there is
  no setting that makes it. That is not a gap — it is what makes the run a test.
  An agent that forgets to tell Slack produces a sandbox where GitHub is correct
  and Slack is silent, and a test that carried the fact for it could never catch
  that.
</Note>

<Warning>
  **Nothing on this page is graded.** No task file, no criteria, no score, and no
  agent eval is charged — an eval is only ever burned when a run is graded, and
  nothing here is. Grading appears exactly once in this curriculum, at the
  [graded capstone](/quickstart/coding-agent).
</Warning>

Every command below was run against a live sandbox and every output block is
pasted back unedited. The ids you see are from that run; yours will differ.

## Before you start

* **A Pome account.** [Sign up](https://app.pome.sh/sign-up), then
  `npx @pome-sh/cli@latest login`. A sandbox runs on our infrastructure, so
  unlike [Get started](/run-a-twin) there is no accountless version of
  this page.
* **Node 24+** for `npx`, plus `curl` and `jq`. Your agent can read the raw JSON
  without `jq`; it is here to keep the blocks short.
* **Your own coding agent.** No `ANTHROPIC_API_KEY` and no model inference paid
  for by Pome: your agent is both the operator and the actor.

## Start one sandbox with two twins

Pass `--twin` once per twin. The order does not matter.

```bash theme={"dark"}
npx @pome-sh/cli@latest sandbox create \
  --twin github --twin slack \
  --secrets-file .pome-sandbox.env --json \
  | jq '{session_id, expires_at, per_twin: (.per_twin|map_values(.api_url))}'
```

```json theme={"dark"}
{
  "session_id": "ses_L2RwKae6MWKMg1v2",
  "expires_at": "2026-08-29T18:39:28.846Z",
  "per_twin": {
    "github": "https://twins.pome.sh/github/s/ses_L2RwKae6MWKMg1v2",
    "slack": "https://twins.pome.sh/slack/s/ses_L2RwKae6MWKMg1v2"
  }
}
```

Read the shape of that. **One** `session_id`. **One** `expires_at`. A `per_twin`
entry per twin, each with its own address — and the same id sitting as a segment
inside both. One thing you hold; two places to call.

<Note>
  The `jq` above is a projection, not the whole response. Unprojected it also
  carries an `mcp_url` and an `openapi_url` per twin, a `provider_credentials` map
  of vendor-shaped tokens the twins hand out to code running *inside* the world,
  and an `agent_token`. Drop the pipe to see all of it. None of those is the bearer
  you call with — that comes from the secrets file below.
</Note>

## One credential, two addresses

`--secrets-file` writes the values as a shell file. Source it and every twin in
the sandbox is reachable from the same shell.

```bash theme={"dark"}
source .pome-sandbox.env
sed -n 's/^export \([A-Z_]*\)=.*/\1/p' .pome-sandbox.env
```

```text theme={"dark"}
POME_AUTH_TOKEN
POME_SESSION_ID
POME_TWIN_URL
POME_TWIN_NAMES
POME_GITHUB_REST_URL
POME_GITHUB_MCP_URL
POME_GITHUB_TOKEN
POME_SLACK_REST_URL
POME_SLACK_MCP_URL
POME_SLACK_TOKEN
```

Ten variables, and the count is the lesson. Each twin brought **three** of its
own — a REST address, an MCP address, and a vendor-shaped token for code running
inside its world. The other four belong to the sandbox, however many twins you
asked for. `POME_AUTH_TOKEN` is the important one: **a single bearer, and it
opens both twins.**

Two helpers keep the rest of the page short, and they differ only in which URL
they call — the bearer is the same variable in both.

```bash theme={"dark"}
gh() { curl -sH "Authorization: Bearer $POME_AUTH_TOKEN" \
  -H 'Content-Type: application/json' "$POME_GITHUB_REST_URL$1" "${@:2}"; }
sl() { curl -sH "Authorization: Bearer $POME_AUTH_TOKEN" \
  -H 'Content-Type: application/json' "$POME_SLACK_REST_URL$1" "${@:2}"; }
```

<Warning>
  **That one token is what opens both twins, and it opens nothing else.** Send it
  to a *different* sandbox's URL and the answer is

  `404 No twin pod for this session.` — the same opaque refusal a wrong bearer
  gets, because the routing layer answers before any twin does. So a 404 on a
  sandbox you just created is almost always the wrong URL or the wrong token
  rather than a dead sandbox.
</Warning>

## Hand your agent the work

The task crosses both twins on purpose: triage the bug in GitHub, then tell the
channel. Paste this as-is.

```text theme={"dark"}
A Pome sandbox is running two digital twins. The GitHub twin is at
$POME_GITHUB_REST_URL, the Slack twin is at $POME_SLACK_REST_URL,
and $POME_AUTH_TOKEN is the bearer on every call to either one.

Show me this plan first and wait for my go-ahead. Keep every
command plain text; never pipe a remote script into a shell.

1. Read the GitHub world before you touch it:
     GET $POME_GITHUB_REST_URL/repos/acme/api/issues
   Tell me what is open and what it says.

2. Triage that issue through the twin's own GitHub surface --
   the same routes and payloads api.github.com takes:
     POST .../issues/<n>/comments   a repro line drawn from the
                                    issue body, not invented
     POST .../issues/<n>/assignees  ["alice"]
   Do not open a new issue and do not close this one.

3. Now tell the team, in the OTHER twin. Read the channel list
   first (Slack's methods sit at the bare path here --
   /conversations.list, not /api/conversations.list), then post
   one message to #general that NAMES the issue you just
   triaged, by repo and number. Nothing carries that number
   across for you: whatever you put in the message body is
   whatever you read out of GitHub yourself.
   Do not post in #random and do not create a channel.

4. Show me each twin's own record of what it served, one line
   per call:
     GET $POME_GITHUB_REST_URL/_pome/events
     GET $POME_SLACK_REST_URL/_pome/events
   Tell me which calls appear on which tape.

This is a twin, not an exam. Nothing here is scored and Pome is
not evaluating you. Keep your own evaluator and observability
setup exactly as it is -- just read the tapes when you are done.
```

## The GitHub half

```bash theme={"dark"}
gh /repos/acme/api/issues | jq -r '.[] | "#\(.number) \(.title)"'
```

```text theme={"dark"}
#1 500 error on POST /orders after deploy
```

One open bug, in `acme/api`. Triage it:

```bash theme={"dark"}
gh /repos/acme/api/issues/1/comments \
  -d '{"body":"Repro confirmed on POST /orders since the 14:00 deploy."}' \
  | jq -c '{id, user: .user.login}'
```

```json theme={"dark"}
{"id":1,"user":"pome-agent"}
```

```bash theme={"dark"}
gh /repos/acme/api/issues/1/assignees -d '{"assignees":["alice"]}' \
  | jq -c '{number, assignee: .assignee.login}'
```

```json theme={"dark"}
{"number":1,"assignee":"alice"}
```

## The Slack half, and the carry

```bash theme={"dark"}
sl /conversations.list | jq -r '.channels[] | "\(.id) #\(.name)"'
```

```text theme={"dark"}
C_GENERAL #general
C_RANDOM #random
```

Now the step the whole page is about. `acme/api#1` came out of the GitHub
response above and goes into the Slack message body here, and the thing that
moved it those two lines is **the agent**:

```bash theme={"dark"}
sl /chat.postMessage \
  -d '{"channel":"C_GENERAL","text":"Triaged acme/api#1, assigned to alice."}' \
  | jq -c '{ok, channel, ts}'
```

```json theme={"dark"}
{"ok":true,"channel":"C_GENERAL","ts":"1788026985.000003"}
```

No join, no shared table, no event bus, no forwarding rule behind that. Slack
was told because something outside both twins chose to tell it.

## Read the tapes — one per twin

Each twin recorded the calls it served, and only those.

```bash theme={"dark"}
gh /_pome/events | jq -r '.[] | "\(.status) \(.method) \(.path)"'
```

```text theme={"dark"}
200 GET /s/ses_L2RwKae6MWKMg1v2/repos/acme/api/issues
201 POST /s/ses_L2RwKae6MWKMg1v2/repos/acme/api/issues/1/comments
201 POST /s/ses_L2RwKae6MWKMg1v2/repos/acme/api/issues/1/assignees
```

```bash theme={"dark"}
sl /_pome/events | jq -r '.[] | "\(.status) \(.method) \(.path)"'
```

```text theme={"dark"}
200 GET /s/ses_L2RwKae6MWKMg1v2/conversations.list
200 POST /s/ses_L2RwKae6MWKMg1v2/chat.postMessage
```

Read those two blocks together.
**The same `ses_L2RwKae6MWKMg1v2` is a segment in every path on both tapes** —
one sandbox, and both twins know they are inside it. **And the two row sets are
disjoint.** The `chat.postMessage` that Slack served is nowhere on GitHub's
tape; neither GitHub write appears on Slack's. Not summarised there, not
cross-referenced there — absent.

That is mechanical rather than a matter of reading — each tape says so itself:

```bash theme={"dark"}
gh /_pome/events | jq -c '[.[].twin] | unique'
sl /_pome/events | jq -c '[.[].twin] | unique'
```

```text theme={"dark"}
["github"]
["slack"]
```

Reading a tape does not add a row to it, so those blocks are the same however
many times you run them. Ask for a whole row and you get what a check reads:

```bash theme={"dark"}
gh /_pome/events | jq '.[1] | {tool, status, state_mutation, fidelity}'
```

```json theme={"dark"}
{
  "tool": "add_issue_comment",
  "status": 201,
  "state_mutation": true,
  "fidelity": "semantic"
}
```

`state_mutation: true` means the call *landed* — a write the twin refuses reads
`false`. The `tool` field is the twin's own action vocabulary, stamped whether
the call arrived over REST or over MCP; the assignees row has none, because only
a short list of GitHub actions is stamped by name today.

## The other half of isolation: a second sandbox

Two twins in one sandbox do not share state. Neither do two sandboxes — and that
is the claim that is easy to fake, because reading a fresh sandbox *after*
stopping the first proves nothing: an empty world is what a reset looks like too.

So start a second one and **leave the first running**.

```bash theme={"dark"}
npx @pome-sh/cli@latest sandbox create \
  --twin github --twin slack \
  --secrets-file .pome-second.env --json \
  | jq -r '"\(.session_id)  \(.per_twin | keys | join(", "))"'
```

```text theme={"dark"}
ses_GM0u7soABHXFkeMf  github, slack
```

```bash theme={"dark"}
npx @pome-sh/cli@latest sandbox list --json \
  | jq -r '.[] | "\(.id) \(.twins|join("+")) \(.state)"'
```

```text theme={"dark"}
ses_GM0u7soABHXFkeMf github+slack ready
ses_L2RwKae6MWKMg1v2 github+slack ready
```

Both open, both holding the same two twins. Point a second pair of helpers at
the new one, keeping the first pair working:

```bash theme={"dark"}
source .pome-second.env
B_TOKEN=$POME_AUTH_TOKEN
B_GITHUB=$POME_GITHUB_REST_URL
B_SLACK=$POME_SLACK_REST_URL
source .pome-sandbox.env

gh2() { curl -sH "Authorization: Bearer $B_TOKEN" "$B_GITHUB$1"; }
sl2() { curl -sH "Authorization: Bearer $B_TOKEN" "$B_SLACK$1"; }
```

Now ask both sandboxes the same question, in flight at the same moment:

```bash theme={"dark"}
gh  /repos/acme/api/issues/1 > one.json &
gh2 /repos/acme/api/issues/1 > two.json &
wait
jq -c '{assignee:.assignee.login, comments}' one.json two.json
```

```json theme={"dark"}
{"assignee":"alice","comments":1}
{"assignee":null,"comments":0}
```

```bash theme={"dark"}
sl  '/conversations.history?channel=C_GENERAL' > one.json &
sl2 '/conversations.history?channel=C_GENERAL' > two.json &
wait
jq -r '.messages[0].text' one.json two.json
```

```text theme={"dark"}
Triaged acme/api#1, assigned to alice.
morning :wave:
```

One request, two answers, same instant. The second sandbox's `#general` is still
sitting on its seeded morning greeting, and its issue #1 is still unassigned with
no comments — while the first sandbox, alive on the other side of the `&`, holds
both writes. Sequential reads cannot tell isolation from a reset; this can.

The tape says it a second way. The second sandbox's GitHub tape carries its own
id and one row, the read you just did:

```bash theme={"dark"}
gh2 /_pome/events | jq -r '.[] | "\(.status) \(.method) \(.path)"'
```

```text theme={"dark"}
200 GET /s/ses_GM0u7soABHXFkeMf/repos/acme/api/issues/1
```

## What you could assert here

Nothing above was graded, but a tape and a final state are exactly what a graded
check reads. Each twin declares its own closed set — `GET /v1/checks?twin=<twin>`
prints it, and so does `pome checks`.

That per-twin split is the point, and it is worth seeing in the vocabulary
itself. **There is no check that reads both twins.** A cross-twin claim is made
by conjoining one check on each side, because grading reads what each twin
recorded, and each twin recorded only itself:

| Check                    | Substrate | What it would assert here                                                                                                      | Known limit                                                                                                                                                                                                     |
| ------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `github.tool-was-called` | tape      | The GitHub write **happened** — it reads the record, so an agent that reports triaging the bug without writing anything fails. | Only some GitHub actions carry a `tool` stamp; `add_assignees` is not one of them yet, so it cannot be named this way. [F-1342](https://linear.app/pome-sh/issue/F-1342)                                        |
| `github.issue-assignee`  | final     | The GitHub write **persisted** — it compares a field exactly, rather than hunting for a substring in prose.                    | None known.                                                                                                                                                                                                     |
| `slack.message-contains` | final     | The issue id **arrived in Slack** — this is the assertion that the carry actually happened.                                    | A substring needle can be satisfied by unrelated correct output, so pick one nothing else in the world can produce. `acme/api#1` qualifies; `alice` does not. [F-1333](https://linear.app/pome-sh/issue/F-1333) |

<Warning>
  **The negative half above is a demonstration, not something you can assert
  today.** "No *new* message was posted" is not expressible: `slack.no-message-posted`
  counts a channel's messages including the ones the starting state placed there,
  and Slack declares no seed-versus-final delta check the way GitHub's
  `github.no-new-issues` does. Tracked as
  [F-1340](https://linear.app/pome-sh/issue/F-1340). Until it lands, isolation
  between sandboxes is something you observe, as above, rather than something a
  criterion states.
</Warning>

Pointers, not a task. [Write a task](/docs/authoring-tasks) is where the grammar
lives.

## What this costs, and how to stop it

Two sandboxes were open at once above, with four twins running between them.
Ask the control plane what that cost — `POME_API_KEY` is a team key from the
[dashboard](https://app.pome.sh), not the sandbox bearer:

```bash theme={"dark"}
curl -sH "Authorization: Bearer $POME_API_KEY" \
  https://api.pome.sh/v1/usage | jq -c '{sessions_used}'
```

```json theme={"dark"}
{"sessions_used":2}
```

Two, not four. **Billing follows the sandbox, not the twin** — adding a second
twin to a sandbox costs nothing extra, and opening a second sandbox is what
counts. No agent eval was charged either: an eval is only ever burned when a run
is graded, and nothing here was.

Each sandbox expires on its own 30-minute clock — `expires_at` in the create
response — and stopping one takes the twins inside it with it. Stop both rather
than waiting them out. `$POME_SESSION_ID` is whichever file you sourced last, so
name each one in turn:

```bash theme={"dark"}
source .pome-sandbox.env
npx @pome-sh/cli@latest sandbox stop $POME_SESSION_ID --discard
source .pome-second.env
npx @pome-sh/cli@latest sandbox stop $POME_SESSION_ID --discard
```

```text theme={"dark"}
Stopped sandbox ses_L2RwKae6MWKMg1v2.
Stopped sandbox ses_GM0u7soABHXFkeMf.
```

`--discard` throws the world away instead of keeping it for a report, which is
what you want for an ungraded walkthrough like this one. Then the meter is back
where it started:

```bash theme={"dark"}
npx @pome-sh/cli@latest sandbox list --json | jq -c .
curl -sH "Authorization: Bearer $POME_API_KEY" \
  https://api.pome.sh/v1/usage | jq -c '{sessions_used}'
```

```json theme={"dark"}
[]
{"sessions_used":0}
```

## Next

<CardGroup cols={2}>
  <Card title="The graded capstone" icon="graduation-cap" href="/quickstart/coding-agent">
    The same two twins, now an exam: a sealed agent under test, a deliberate
    failing score, and one sentence of prompt that turns it green.
  </Card>

  <Card title="Cross-twin consistency" icon="git-compare-arrows" href="/docs/examples/cross-twin-consistency">
    A worked example of the failure this page's shape exists to catch — GitHub
    correct, Slack silent, and only an exam reading both states at once notices.
  </Card>
</CardGroup>
