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

# Slack twin in 5 minutes

> Start a private Slack workspace, let your own coding agent answer in the right thread, then read the twin's recorded tape — where the status code cannot tell you whether the message landed. No Slack workspace, no API key, nothing graded.

<Info>
  <Icon icon="clock" /> **About 5 minutes.** You start a private Slack workspace
  with two channels and an unanswered morning message, your own coding agent
  answers inside that thread, and you read the twin's own record of every call it
  made — including the one column that says whether the message actually landed.
</Info>

A **digital twin** is not a mock. It is a stateful service that answers the same
Web API and MCP calls as `slack.com/api`, boots from a declared starting state,
records every request, and never touches Slack. Reply in a thread here and the
parent message's reply count really moves — inside this sandbox, and nowhere
else.

## Before you start

* **A Pome account.** `pome login` opens the browser sign-in and creates one if
  you do not have it. No credit card.
* **Your own coding agent** — Claude Code, Cursor, anything that can run a shell
  command and read JSON back.
* **Node 18+** for `npx`, plus `curl` and `jq` for the transcripts below. Your
  agent can read the raw JSON without `jq`; it is here to keep the blocks short.

Nothing else. No Slack workspace, no `ANTHROPIC_API_KEY`, no model inference
paid for by Pome: **your agent is both the operator and the actor.** It drives
Pome, and it is the thing that acts on the twin.

<Note>
  **Nothing on this page is graded.** No task file, no criteria, no score — and no
  agent eval is charged, because an eval is only ever burned when a run is graded.
  A sandbox you start, drive and stop costs you nothing. Grading appears exactly
  once in this curriculum, at the
  [support-triage capstone](/quickstart/claude-code), where the agent under test
  is sealed off from the criteria that judge it.
</Note>

## Paste this

Hand this to your coding agent as-is. It names the twin's own Web API surface
and the boundary it must not cross.

```text theme={"dark"}
Set up a Pome Slack sandbox, answer the morning thread in it,
then show me the recorded tape.

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. Sign in and start the world:
     npx @pome-sh/cli@latest login
     npx @pome-sh/cli@latest sandbox create --twin slack \
       --secrets-file .pome-sandbox.env --format json
   Then source .pome-sandbox.env. POME_AUTH_TOKEN is the bearer
   for EVERY call to the twin and the only one it accepts: any
   other token, or none, comes back as an opaque 404. Keep it in
   the shell, and add .pome-sandbox.env to .gitignore.

2. Read the world before you touch it. The twin serves Slack's
   methods at the BARE path -- /conversations.list, not
   /api/conversations.list:
     GET $POME_SLACK_REST_URL/conversations.list
     GET .../conversations.history?channel=C_GENERAL
   Tell me which channels exist and who said what.

3. Answer IN THE THREAD, not as a new top-level message. Slack
   threads hang off the parent's ts, and the seed does not fix
   those values -- they are minted when the sandbox boots, so
   read Alice's ts out of the history first, then:
     chat.postMessage  POST /chat.postMessage with channel,
                       thread_ts set to Alice's ts, and a
                       one-line acknowledgement.
     reactions.add     POST /reactions.add on the SAME ts.
   Do not post in #random and do not create a channel.

4. Read the result back THROUGH THE TWIN, not from your memory
   of what you sent:
     GET .../conversations.replies?channel=C_GENERAL&ts=<ts>

5. Show me the tape, one line per call, in the order they
   happened: GET $POME_SLACK_REST_URL/_pome/events
   Slack answers 200 whether or not a write succeeded, so tell
   me which rows carry state_mutation: true.

6. Stop the sandbox:
     npx @pome-sh/cli@latest sandbox stop $POME_SESSION_ID --discard

This is a sandbox, 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 tape when you are done.
```

<Warning>
  **One token opens a sandbox: `POME_AUTH_TOKEN`.** It is the bearer on every call
  to the twin, REST and MCP alike. On most twins the secrets file also carries a
  provider-shaped token — `POME_GITHUB_TOKEN=github_pat_…`,
  `POME_STRIPE_API_KEY=sk_test_…` — and that one is *not* the bearer; it is what
  the twin serves inside the sandbox. The linear twin ships none at all.
  Send that provider-shaped token as the bearer, or send no bearer at all, and the
  proxy answers an opaque `404` reading

  `No twin pod for this session.` So a 404 on a sandbox you just created is almost
  always the wrong bearer rather than a dead sandbox.
</Warning>

## The world

`sandbox create` boots a Slack twin from its declared starting state and hands
back the URLs that reach it. It also writes the connection secrets to
`.pome-sandbox.env` at mode `0600`, and says so on stderr.

```bash theme={"dark"}
npx @pome-sh/cli@latest sandbox create --twin slack \
  --secrets-file .pome-sandbox.env --format json
```

```json theme={"dark"}
{
  "session_id": "ses_rPnTAmheZgRHm6IM",
  "expires_at": "2026-08-25T10:20:19.664Z",
  "per_twin": {
    "slack": {
      "api_url": "https://twins.pome.sh/slack/s/ses_rPnTAmheZgRHm6IM",
      "mcp_url": "https://twins.pome.sh/slack/s/ses_rPnTAmheZgRHm6IM/mcp"
    }
  },
  "agent_token": "***redacted***"
}
```

Every call below goes to that `api_url` with the one bearer the sandbox accepts.
Note the bare method paths: this twin mounts `conversations.list`, not
`api/conversations.list`, and a wrong prefix comes back `501` with every served
surface listed in the body.

```bash theme={"dark"}
source .pome-sandbox.env

sl() {
  curl -s -H "Authorization: Bearer $POME_AUTH_TOKEN" \
       "$POME_SLACK_REST_URL$1" "${@:2}"
}
```

The world is one workspace with two channels — one with history, one empty:

```bash theme={"dark"}
sl /conversations.list | jq -c '.channels[] | {id, name, num_members}'

sl "/conversations.history?channel=C_GENERAL" \
  | jq -r '.messages[]
      | "\(.ts)  \(.user)  replies=\(.reply_count)  \(.text)"'
```

```text theme={"dark"}
{"id":"C_GENERAL","name":"general","num_members":3}
{"id":"C_RANDOM","name":"random","num_members":0}
```

```text theme={"dark"}
1787652639.000002  U_BOB  replies=null  morning :wave:
1787652639.000001  U_ALICE  replies=null  morning team
```

Two greetings, no replies, and `#random` untouched. **Read those `ts` values,
never hardcode them.** The seed fixes the *content* of this world, not its
identifiers: message timestamps are minted when the sandbox boots, so yours
differ from the ones above and from your last run. Capture the one you need:

```bash theme={"dark"}
PARENT=$(sl "/conversations.history?channel=C_GENERAL" \
  | jq -r '.messages[] | select(.user == "U_ALICE") | .ts')
```

Now the reply — in Alice's thread, then a reaction on the same message:

```bash theme={"dark"}
sl /chat.postMessage -X POST -H 'content-type: application/json' \
   -d "{\"channel\":\"C_GENERAL\",\"thread_ts\":\"$PARENT\",
        \"text\":\"Picked up - running the morning checks now.\"}" \
   | jq -c '{ok, ts, thread_ts: .message.thread_ts}'

sl /reactions.add -X POST -H 'content-type: application/json' \
   -d "{\"channel\":\"C_GENERAL\",\"timestamp\":\"$PARENT\",
        \"name\":\"white_check_mark\"}" | jq -c .
```

```text theme={"dark"}
{"ok":true,"ts":"1787652644.000003","thread_ts":"1787652639.000001"}
{"ok":true}
```

Read the thread back through the twin's own surface — not out of the response
your agent already holds:

```bash theme={"dark"}
sl "/conversations.replies?channel=C_GENERAL&ts=$PARENT" \
  | jq -r '.messages[] | "\(.user)  replies=\(.reply_count)  \(.text)"'
```

```text theme={"dark"}
U_ALICE  replies=1  morning team
U_PRIMARY  replies=null  Picked up - running the morning checks now.
```

**This is the thing worth noticing.** Alice's message read `replies=null` before
the write and reads `1` after it, and the reply comes back nested under her `ts`
rather than sitting beside it in the channel. A mock has no parent to update.
Stop the sandbox and that thread is gone; create another and `#general` is two
unanswered greetings again.

## Read the tape

Every call above was recorded by the twin as it happened. This is the part
neither a mock nor a staging workspace gives you: an account of the run written
by the service, not by the agent.

```bash theme={"dark"}
sl /_pome/events | jq -r '.[]
  | "\(.method|(.+"     ")[:5])\(.path|sub("^/s/[^/]+";"")) -> \(.status)"'
```

```text theme={"dark"}
GET  /conversations.list -> 200
GET  /conversations.history -> 200
GET  /conversations.history -> 200
POST /chat.postMessage -> 200
POST /reactions.add -> 200
GET  /conversations.replies -> 200
```

Six rows and every one of them is `200`. That is not the recording being lazy —
it is Slack's actual contract, and it is why the request line alone cannot tell
you what happened. Ask for the column that can:

```bash theme={"dark"}
sl /_pome/events | jq -r '.[] | select(.state_mutation)
  | "\(.path|sub("^/s/[^/]+";""))  \(.status)  tool=\(.tool)"
  + "  mut=\(.state_mutation)  fid=\(.fidelity)"'
```

```text theme={"dark"}
/chat.postMessage  200  tool=null  mut=true  fid=semantic
/reactions.add  200  tool=null  mut=true  fid=semantic
```

**You should see:**

* **Six rows, in the order they happened.** Read top to bottom and the exchange
  is legible without asking the agent what it did: it looked twice, it wrote
  twice, it checked.
* **`mut=true` on exactly the two writes.** `state_mutation` means the call
  *landed*. On this twin that column is the whole verdict, because Slack answers
  `200` to a refused write as readily as an accepted one.
* **`tool=null` on every row.** Unlike the GitHub twin, this one stamps no action
  vocabulary; calls are identified by method and path, which is why the
  assertable checks below read final state rather than the tape.
* **`fid=semantic`.** These surfaces carry a full behavioural contract, not a
  response shape with placeholder values.

Try it against the failure that actually happens — a channel name your agent
guessed at:

```bash theme={"dark"}
sl /chat.postMessage -X POST -H 'content-type: application/json' \
   -d '{"channel":"#suport","text":"Picked up."}' \
   -w ' HTTP %{http_code}\n'

sl /_pome/events | jq '.[-1] | {
  status, ok: .response_body.ok,
  error: .response_body.error, state_mutation
}'
```

```text theme={"dark"}
{"ok":false,"error":"channel_not_found"} HTTP 200
```

```json theme={"dark"}
{
  "status": 200,
  "ok": false,
  "error": "channel_not_found",
  "state_mutation": false
}
```

An agent that reports "posted to #support" is not lying about the status code —
it really did get a `200`. The tape is what says nothing was written. When you
are done, stop the sandbox to free the slot; it also expires on its own 30
minutes after it was created:

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

## What you could assert here

Nothing on this page was graded, but a tape and a final state are exactly what a
graded check reads. The Slack twin already declares these — no authoring
required, and `list_checks` on the Pome MCP prints the full set with what each
one actually compares:

| Declared check                                                            | What it would catch here                                                                                                                                                                                                                                                                                                                                                                                  |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `slack.message-contains` (`general`, `morning checks`)                    | An agent that reports acknowledging the thread without ever posting. It reads the channel's final messages, so an agent that does nothing fails it instead of collecting free points. Matching is case-insensitive: pick the substring every honest phrasing shares, not the sentence you imagine it writing.                                                                                             |
| `slack.no-message-containing` (`channel_not_found`, `any public channel`) | An agent that pastes its own raw error output into a channel instead of handling it — the `channel_not_found` body above is exactly the string that ends up inside a "sorry, something went wrong" message. It hunts the needle as a case-sensitive substring inside free prose, so a redactor that destroys it makes the check skip as `subject_redacted` rather than pass over state it could not read. |
| `slack.no-secret-newly-exposed`                                           | An agent that pastes a key into a public channel while explaining itself. It compares redaction markers between the starting and final state, so it never has to hold the secret to catch the leak, and it fires on a secret the agent introduced itself.                                                                                                                                                 |

Pointers, not a task. Turning them into a graded exam is the
[capstone](/quickstart/claude-code) below, and
[Write a task](/docs/authoring-tasks) is where the grammar lives.

## How real is this twin?

Every surface you called above is compared against a captured response from the
real Slack Web API, re-run daily and published.
[The Slack row on status.pome.sh](https://status.pome.sh) carries the current
count of twin responses that match, how many of Slack's own MCP tools are
covered, and how old the captured baseline is. Nobody has to take our word for
the fidelity, including us — a twin that drifts turns that row red.

Which surfaces are covered, and which are shape-only, is on the
[Slack twin reference](/docs/twins/slack).

## Next: the one graded lesson

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

  <Card title="Slack twin reference" icon="slack" href="/docs/twins/slack">
    Every method and MCP tool the twin serves, by use case, with its fidelity tier.
  </Card>

  <Card title="Write a task" icon="pen-line" href="/docs/authoring-tasks">
    Turn the checks above into a graded exam for your own agent.
  </Card>

  <Card title="pome sandbox" icon="terminal" href="/docs/cli/sandbox">
    Create, list and stop sandboxes — including multi-twin ones.
  </Card>
</CardGroup>
