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

# Gmail twin in 5 minutes

> Start a private mailbox holding an unanswered customer email, let your own coding agent reply and file it, then read the twin's recorded tape — including what it deliberately refuses to keep. No Google account, no OAuth consent, nothing graded.

<Info>
  <Icon icon="clock" /> **About 5 minutes.** You start a private mailbox holding
  one unanswered customer email, your own coding agent replies to it and files it,
  and you read the twin's own record of every call it made — including what the
  recorder keeps about a sent message and what it deliberately throws away.
</Info>

A **digital twin** is not a mock. It is a stateful service that answers the same
REST and MCP calls as `gmail.googleapis.com`, boots from a declared starting
state, records every request, and never reaches Google. No mailbox is connected
and no OAuth consent is asked for, because there is no real account behind it to
consent to: the hosted twin holds no Google credential, ever.

## 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 Google account, 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 Gmail surface and
the boundary it must not cross.

```text theme={"dark"}
Set up a Pome Gmail sandbox, answer the unread customer email 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 gmail \
       --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 routes are Gmail's
   own, under /gmail/v1/users/me:
     GET $POME_GMAIL_REST_URL/gmail/v1/users/me/profile
     GET .../gmail/v1/users/me/messages?q=is:unread
     GET .../gmail/v1/users/me/messages/msg_support
   Tell me who is waiting on a reply and what they asked.

3. Answer it, through the twin's own Gmail surface -- the same
   routes and payloads gmail.googleapis.com takes:
     messages/send  POST with a base64url RFC822 message in
                    `raw` and threadId set to the inquiry's
                    thread, so the reply threads instead of
                    starting a new conversation. Set In-Reply-To
                    and References to the inquiry's Message-ID,
                    and include a From header -- this twin
                    requires one rather than inferring it.
     modify         POST messages/msg_support/modify to remove
                    UNREAD and add the existing Label_follow_up.
   Send exactly ONE reply. Do not delete anything, and do not
   touch the other two threads.

4. Read the result back THROUGH THE TWIN, not from your memory
   of what you sent:
     GET .../gmail/v1/users/me/threads/thread_support
     GET .../gmail/v1/users/me/messages?labelIds=SENT

5. Show me the tape, one line per call, in the order they
   happened: GET $POME_GMAIL_REST_URL/_pome/events

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 Gmail 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 gmail \
  --secrets-file .pome-sandbox.env --format json
```

```json theme={"dark"}
{
  "session_id": "ses_PbRt4mOQreRbIA0P",
  "expires_at": "2026-08-25T10:23:04.482Z",
  "per_twin": {
    "gmail": {
      "api_url": "https://twins.pome.sh/gmail/s/ses_PbRt4mOQreRbIA0P",
      "mcp_url": "https://twins.pome.sh/gmail/s/ses_PbRt4mOQreRbIA0P/mcp"
    }
  },
  "provider_credentials": {},
  "agent_token": "***redacted***"
}
```

That empty `provider_credentials` is not an omission — it is the point. There is
no upstream Gmail credential to hand you, so the twin serves its own mailbox and
nothing you do here can reach a real one.

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

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

The world is one mailbox with four threads, two of them unread:

```bash theme={"dark"}
gm /gmail/v1/users/me/profile | jq -r '"\(.emailAddress)"
  + "  messages=\(.messagesTotal)  threads=\(.threadsTotal)"'

gm "/gmail/v1/users/me/messages?q=is:unread" \
  | jq -r '.messages[] | "\(.id)  \(.threadId)"'
```

```text theme={"dark"}
pome-agent@pome-twin.test  messages=5  threads=4
```

```text theme={"dark"}
msg_support  thread_support
msg_build  thread_build
```

One of those two is a customer waiting on an answer. Read it:

```bash theme={"dark"}
gm /gmail/v1/users/me/messages/msg_support | jq -r '
  "labels:  \(.labelIds | join(", "))",
  "from:    \((.payload.headers[]|select(.name=="From")|.value))",
  "subject: \((.payload.headers[]|select(.name=="Subject")|.value))",
  "msg-id:  \((.payload.headers[]|select(.name=="Message-ID")|.value))",
  "body: \(.snippet)"'
```

```text theme={"dark"}
labels:  INBOX, UNREAD
from:    alice@example.com
subject: Production export is stuck
msg-id:  <support-001@example.com>
body: Our production export has been stuck for an hour. Can you investigate?
```

Unread, unanswered, and the same every time you create this sandbox — which is
what makes anything you observe next reproducible. Now the reply. Gmail takes a
whole RFC822 message, base64url-encoded, in `raw`:

```bash theme={"dark"}
RAW=$(printf '%s\n' \
  'From: pome-agent@pome-twin.test' \
  'To: alice@example.com' \
  'Subject: Re: Production export is stuck' \
  'In-Reply-To: <support-001@example.com>' \
  'References: <support-001@example.com>' \
  '' \
  'Looking at the stuck export now - will update within the hour.' \
  | base64 | tr -d '\n' | tr '+/' '-_')

gm /gmail/v1/users/me/messages/send -X POST \
   -H 'content-type: application/json' \
   -d "{\"raw\":\"$RAW\",\"threadId\":\"thread_support\"}" \
   | jq -c '{id, threadId, labelIds}'

gm /gmail/v1/users/me/messages/msg_support/modify -X POST \
   -H 'content-type: application/json' \
   -d '{"removeLabelIds":["UNREAD"],
        "addLabelIds":["Label_follow_up"]}' | jq -c '{id, labelIds}'
```

```text theme={"dark"}
{"id":"msg_0000000000000005","threadId":"thread_support","labelIds":["SENT"]}
{"id":"msg_support","labelIds":["INBOX","Label_follow_up"]}
```

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

```bash theme={"dark"}
gm /gmail/v1/users/me/threads/thread_support | jq -r '.messages[]
  | "\(.id)  [\(.labelIds|join(","))]  "
  + "\((.payload.headers[]|select(.name=="From")|.value))"'

gm "/gmail/v1/users/me/messages?labelIds=SENT" \
  | jq -c '{n: (.messages|length), ids: [.messages[].id]}'
```

```text theme={"dark"}
msg_support  [INBOX,Label_follow_up]  alice@example.com
msg_0000000000000005  [SENT]  pome-agent@pome-twin.test
```

```text theme={"dark"}
{"n":2,"ids":["msg_0000000000000005","msg_build_reply"]}
```

**This is the thing worth noticing.** The reply joined Alice's thread rather than
starting a fourth one, and `msg_support` lost `UNREAD` and gained a label — read
back through two different routes. `SENT` now holds two messages, not one: the
mailbox started with a sent reply on the build thread, so **two is the number
that means "replied exactly once"**, and that is the number a check has to
assert. Stop the sandbox and all of it is gone.

## Read the tape

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

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

```text theme={"dark"}
GET  /gmail/v1/users/me/profile -> 200
GET  /gmail/v1/users/me/messages -> 200
GET  /gmail/v1/users/me/messages/msg_support -> 200
POST /gmail/v1/users/me/messages/send -> 200
POST /gmail/v1/users/me/messages/msg_support/modify -> 200
GET  /gmail/v1/users/me/threads/thread_support -> 200
GET  /gmail/v1/users/me/messages -> 200
```

Each row carries more than the request line:

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

```text theme={"dark"}
/messages/send  200  tool=null  mut=true  fid=semantic
/messages/msg_support/modify  200  tool=null  mut=true  fid=semantic
```

**You should see:**

* **Seven rows, in the order they happened.** Read top to bottom and the triage
  is legible without asking the agent what it did: it looked, it read the
  inquiry, it replied, it filed, it checked twice.
* **`mut=true` on exactly the two writes.** `state_mutation` means the call
  *landed* — a write the twin refuses reads `false`, not `true`. Send the same
  message with no `From` header and you get a `400` with `mut=false` beside it.
* **`tool=null` on every row.** Unlike the GitHub twin, this one stamps no action
  vocabulary; calls are identified by method and path.
* **`fid=semantic`.** These surfaces carry a full behavioural contract, not a
  response shape with placeholder values.

Now the part that is specific to mail. Ask the recorder what it kept about the
message you just sent:

```bash theme={"dark"}
gm /_pome/events | jq '[.[] | select(.state_delta)
  | .state_delta.after.messages[]?] | last
  | {id, threadId, subject, sentAt, bodyOmitted,
     body_bytes: .textSha256.size, headerCount}'

gm /_pome/events | jq -r '[.[] | select(.state_delta)
  | .state_delta.after.messages[]?] | last | .textSha256.sha256'
```

```json theme={"dark"}
{
  "id": "msg_0000000000000005",
  "threadId": "thread_support",
  "subject": "Re: Production export is stuck",
  "sentAt": "2026-07-20T00:00:01.000Z",
  "bodyOmitted": true,
  "body_bytes": 63,
  "headerCount": 5
}
```

```text theme={"dark"}
afcafdd1cb3c9dc55df1511b3d28457b7eb2d4171747d6210a80afac6e7a2260
```

Two things there are worth a second look. **`bodyOmitted: true`** — the API
served you that body a moment ago, but the recorded state keeps a SHA-256 and a
byte count in its place, so the evidence a grader reads can prove *which* message
was sent without holding what it said. And **`sentAt` is
`2026-07-20T00:00:01.000Z`**, one second past the mailbox's own clock rather than
the wall clock you ran this at: the twin advances a logical clock per write, so
running this twice gives you the same timestamp and the same hash.

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 Gmail 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                                                                                                                                                                                                                                                                                                                                                     |
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gmail.mailbox-label-count` (`pome-agent@pome-twin.test`, `2`, `SENT`) | An agent that replies twice — the retry after a response it thought it lost. The count is exact rather than a lower bound, so three sent messages fail it. And because the mailbox seeds one sent message, an agent that never replies reads `1` and fails too.                                                                                                              |
| `gmail.message-has-label` (`msg_support`, `Follow Up`)                 | An agent that answers the customer and never files the mail, so the next person through the inbox cannot tell it was handled. It asks only whether that one label is on the message — it says nothing about the others, so `INBOX` staying put is fine and `UNREAD` staying put would not fail it. The label may be named by its id (`Label_follow_up`) or its display name. |
| `gmail.no-unsupported-endpoint`                                        | An agent that guesses at the route prefix. Gmail's paths are easy to get almost right — drop the leading `/gmail` segment and the twin answers `501`, which is exactly what this scans the tape for.                                                                                                                                                                         |

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 Gmail API, re-run daily and published.
[The Gmail row on status.pome.sh](https://status.pome.sh) carries the current
count of twin responses that match, names every divergence we have ruled on by
id, and states 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
[Gmail twin reference](/docs/twins/gmail).

## 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="Gmail twin reference" icon="mail" href="/docs/twins/gmail">
    Every route 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>
