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

# Linear twin in 5 minutes

> Start a private Linear workspace, let your own coding agent pick up a queued issue and comment on it, then read the twin's recorded tape — where every row is the same GraphQL request line. No Linear workspace, no API key, nothing graded.

<Info>
  <Icon icon="clock" /> **About 5 minutes.** You start a private Linear workspace
  holding four issues, your own coding agent picks up the queued one and says why,
  and you read the twin's own record of every call it made — a tape where the
  request line is identical on every row, which is what makes a GraphQL agent hard
  to audit anywhere else.
</Info>

A **digital twin** is not a mock. It is a stateful service that answers the same
GraphQL and MCP calls as `api.linear.app`, boots from a declared starting state,
records every request, and never touches your workspace. It holds you to a real
schema too: a mutation with the wrong field name is rejected here exactly as
Linear rejects it, instead of cheerfully answering `200`.

## 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`. Here `jq` does double duty: it
  also builds the request bodies, so you never hand-escape a GraphQL document.

Nothing else. No Linear 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 GraphQL surface and
the boundary it must not cross.

```text theme={"dark"}
Set up a Pome Linear sandbox, pick up the queued issue 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 linear \
       --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. Linear has no REST API and
   neither does the twin -- every call is a POST to
   $POME_LINEAR_REST_URL/graphql. Query `viewer`, then `issues`
   with each one's state. Tell me what is queued.

3. Resolve the workflow state BEFORE transitioning anything.
   Linear's state is a row on the team, not a string:
   IssueUpdateInput takes stateId, and passing state: "In
   Progress" is a schema error, not a slow path. So:
     query    team(id: "team_eng") { states { nodes { id name } } }
     mutation issueUpdate(id: "issue_todo",
                input: { stateId: <the In Progress id> })
     mutation commentCreate(input: { issueId: "issue_todo",
                body: <one line on what you are picking up> })
   Move that one issue only. Do not create an issue, do not
   close anything, do not touch ENG-1, ENG-3 or ENG-4.

4. Read the result back THROUGH THE TWIN, not from your memory
   of what you sent: query the issue with its state and comments.

5. Show me the tape: GET $POME_LINEAR_REST_URL/_pome/events
   Every row will read POST /graphql, so tell me which rows carry
   state_mutation: true and what query each row recorded.

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

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

One endpoint takes every call. These two helpers post a GraphQL document to it
and read the tape back:

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

ln() {
  curl -s -H "Authorization: Bearer $POME_AUTH_TOKEN" \
       -H 'content-type: application/json' \
       "$POME_LINEAR_REST_URL/graphql" \
       --data-binary "$(jq -n --arg q "$1" '{query:$q}')"
}

ln_tape() {
  curl -s -H "Authorization: Bearer $POME_AUTH_TOKEN" \
       "$POME_LINEAR_REST_URL/_pome/events"
}
```

The world is one team, `ENG`, with four issues — one in each workflow state:

```bash theme={"dark"}
ln 'query { viewer { id name email } }' | jq -c '.data.viewer'

ln 'query { issues { nodes { identifier title state { name } } } }' \
  | jq -c '.data.issues.nodes[]
      | {id: .identifier, title, state: .state.name}' | sort
```

```text theme={"dark"}
{"id":"user_admin","name":"Admin User","email":"admin@pome-twin.test"}
```

```text theme={"dark"}
{"id":"ENG-1","title":"Triage inbox for agent eval","state":"Backlog"}
{"id":"ENG-2","title":"Ship Linear twin GraphQL surface","state":"Todo"}
{"id":"ENG-3","title":"Wire MCP tools to commands","state":"In Progress"}
{"id":"ENG-4","title":"Seed multi-issue agent world","state":"Done"}
```

ENG-2 is the one thing queued and unstarted, and it is the same every time you
create this sandbox — which is what makes anything you observe next reproducible.
Before moving it, resolve what "In Progress" actually *is* on this team: a row
with an id, not a string.

```bash theme={"dark"}
ln 'query { team(id: "team_eng") { states { nodes { id name } } } }' \
  | jq -c '.data.team.states.nodes[]'
```

```text theme={"dark"}
{"id":"state_backlog","name":"Backlog"}
{"id":"state_todo","name":"Todo"}
{"id":"state_progress","name":"In Progress"}
{"id":"state_done","name":"Done"}
{"id":"state_canceled","name":"Canceled"}
```

Now the two writes — the transition, then the note that says why:

```bash theme={"dark"}
ln 'mutation { issueUpdate(id: "issue_todo",
      input: { stateId: "state_progress" }) {
        success issue { identifier state { name type } } } }' \
  | jq '.data.issueUpdate'
```

```json theme={"dark"}
{
  "success": true,
  "issue": {
    "identifier": "ENG-2",
    "state": {
      "name": "In Progress",
      "type": "started"
    }
  }
}
```

```bash theme={"dark"}
ln 'mutation { commentCreate(input: { issueId: "issue_todo",
      body: "Picked up in standup - starting on the issues query." }) {
        success comment { body user { name } } } }' \
  | jq '.data.commentCreate'
```

```json theme={"dark"}
{
  "success": true,
  "comment": {
    "body": "Picked up in standup - starting on the issues query.",
    "user": {
      "name": "Admin User"
    }
  }
}
```

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

```bash theme={"dark"}
ln 'query { issue(id: "issue_todo") {
      identifier state { name } comments { nodes { body } } } }' \
  | jq '.data.issue | {id: .identifier, state: .state.name,
         comments: [.comments.nodes[].body]}'
```

```json theme={"dark"}
{
  "id": "ENG-2",
  "state": "In Progress",
  "comments": [
    "Starting with viewer + issues queries.",
    "Picked up in standup - starting on the issues query."
  ]
}
```

**This is the thing worth noticing.** ENG-2 comes back in the state you resolved
by id, and the new comment sits *after* the one the workspace already had rather
than replacing it. Two comments, not one — the seeded history survives the write,
which is why "a comment exists" and "the right comment exists" are different
assertions. Stop the sandbox and ENG-2 is queued and quiet 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"}
ln_tape | jq -r '.[]
  | "\(.method) \(.path|sub("^/s/[^/]+";"")) -> \(.status)"
  + "   mut=\(.state_mutation)"'
```

```text theme={"dark"}
POST /graphql -> 200   mut=false
POST /graphql -> 200   mut=false
POST /graphql -> 200   mut=false
POST /graphql -> 200   mut=true
POST /graphql -> 200   mut=true
POST /graphql -> 200   mut=false
```

**Six identical request lines.** Method, path and status carry no information
here — and that is not a gap in the recording, it is what a GraphQL API looks
like from the outside. It is also why a proxy log tells you nothing about what a
Linear agent did. What the twin recorded instead is the document each call sent:

```bash theme={"dark"}
ln_tape | jq -r '.[]
  | "\(if .state_mutation then "MUT " else "read" end)  "
  + "\(.request_body.query | gsub("\\s+";" ") | .[0:44])"'
```

```text theme={"dark"}
read  query { viewer { id name email } }
read  query { issues { nodes { identifier title st
read  query { team(id: "team_eng") { states { node
MUT   mutation { issueUpdate(id: "issue_todo", inp
MUT   mutation { commentCreate(input: { issueId: "
read  query { issue(id: "issue_todo") { identifier
```

**You should see:**

* **Six rows, in the order they happened.** Read top to bottom and the standup is
  legible without asking the agent what it did: it identified itself, it read the
  queue, it resolved the state, it moved one issue, it explained itself, it
  checked.
* **`state_mutation: true` on exactly the two mutations.** It means the call
  *landed* — a write the twin refuses reads `false`, not `true`. On this twin that
  column does the work the method does elsewhere.
* **The recorded document, not just an operation name.** The row keeps the whole
  query, so `issue_todo` being the id that moved is in the evidence rather than in
  the agent's account of itself.
* **`tool: null` on every row.** Unlike the GitHub twin, this one stamps no action
  vocabulary of its own.

Now the schema. Ask for the transition the way an agent that skipped the lookup
would ask for it:

```bash theme={"dark"}
ln 'mutation { issueUpdate(id: "issue_todo",
      input: { state: "In Progress" }) { success } }' \
  | jq -r '.errors[0].message' | fold -sw 68

ln_tape | jq '.[-1] | {status, state_mutation, fidelity}'
```

```text theme={"dark"}
Field "state" is not defined by type "IssueUpdateInput". Did you 
mean "stateId", "estimate", or "title"?
```

```json theme={"dark"}
{
  "status": 400,
  "state_mutation": false,
  "fidelity": "semantic"
}
```

A `400` carrying Linear's own suggestion, with `fidelity: semantic` beside it:
the twin refused this because the schema refuses it, not because the route is
missing. 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 Linear 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                                                                                                                                                                                                                      |
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `linear.issue-state` (`Ship Linear twin GraphQL surface`, `ENG`, `In Progress`) | An agent that comments about picking the issue up and never moves it. It resolves the issue by title and follows `stateId` to the team's workflow-state row, so an issue that was renamed, archived or is missing fails rather than skipping. |
| `linear.issue-comment-contains` (same issue, `ENG`, `standup`)                  | An agent that flips the state silently, leaving nobody able to tell why ENG-2 moved. Pick the substring every honest phrasing would share — the needle is hunted inside free prose, not compared to a field.                                  |
| `linear.no-unsupported-endpoint`                                                | An agent that assumes Linear has a REST API. `GET /issues` looks reasonable and is answered `501`; this scans the tape for exactly that, and passes the `400` above, because a schema rejection is a real answer rather than a missing route. |

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 Linear API, re-run daily and published.
[The Linear row on status.pome.sh](https://status.pome.sh) carries the current
count of twin responses that match, how many of Linear'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
[Linear twin reference](/docs/twins/linear).

## 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="Linear twin reference" icon="list-check" href="/docs/twins/linear">
    Every query, mutation and MCP tool the twin serves, 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>
