# Linear simulator

A stateful Linear GraphQL simulator — issues, comments, labels, projects and documents for agents that work in a workspace.

`linear-twin` is a local, stateful simulator of Linear's GraphQL API — the surface a coding or office agent works in: issues, comments, labels, relations, projects with milestones and status updates, documents and attachments. It never contacts Linear, and no test issue lands in a real workspace.

## Quickstart

You need an API key from [Account → API keys](https://www.rystic.ai/account/api-keys) ([how](https://www.rystic.ai/docs/licensing.md#get-an-api-key)).

```bash
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…                                                  # your API key
rystic run linear-twin -d --port 0 --scenario linear_sprint_board   # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version
```

Expect `"product":"linear-twin"` and `"version":"0.0.1"`. An older version means an older install; `rystic pull linear-twin` upgrades it. `--scenario linear_sprint_board` boots the same workspace on every plan. The examples below use `localhost:8080`; use the port `rystic run` printed. The archive also carries `check.sh`, `HOWTO.md` and `SCENARIOS.md`.

On Enterprise, `rystic login ./rystic-license.json` takes the place of the key ([license files](https://www.rystic.ai/docs/licensing.md#enterprise-license-files)).

Read the board, then file an issue in its Engineering team's Todo state:

```bash
curl -s localhost:8080/graphql -H 'Authorization: lin_placeholder' -H 'Content-Type: application/json' \
  -d '{"query":"{ issues { nodes { identifier title state { name } assignee { name } } } }"}'
# -> {"data":{"issues":{"nodes":[{"identifier":"ENG-106","title":"Upgrade pg driver to 8.13","state":{"name":"Done"},"assignee":{"name":"Yuki Sato"}},...]}}}

curl -s localhost:8080/graphql -H 'Authorization: lin_placeholder' -H 'Content-Type: application/json' \
  -d '{"query":"mutation { issueCreate(input: {teamId: \"5b0a4d00-7ea0-4a00-8b00-000000000001\", stateId: \"5b0a4d00-57a7-4a00-8b00-000000000002\", title: \"First issue\"}) { success issue { id title state { name } } } }"}'
# -> {"data":{"issueCreate":{"success":true,"issue":{"id":"00000001-0000-4000-8001-000000000001","title":"First issue","state":{"name":"Todo"}}}}}
```

Point the SDK at it — the API URL only, code unchanged:

```ts
const linear = new LinearClient({ apiKey: "lin_placeholder", apiUrl: "http://localhost:8080/graphql" });
```

## Plans

What `linear-twin` does on each plan ([plans](https://www.rystic.ai/docs/licensing.md#plans)):

| | Free | Pro | Enterprise |
|---|---|---|---|
| Credential | API key | API key | license file |
| Boots | `linear_sprint_board` | an empty workspace, or the `--scenario` you name | same as Pro |
| `POST /_rystic/reset` | re-applies `linear_sprint_board` | wipes to empty | wipes to empty |
| Linear GraphQL (all 58 operations) | yes | yes | yes |
| Control plane | `version`, `routes`, `reset` only | every route | every route |

**Free** runs 5 hours of simulator time a month. It serves the whole GraphQL surface against `linear_sprint_board`. Every control route beyond `GET /_rystic/version`, `GET /_rystic/routes` and `POST /_rystic/reset` answers 403, naming the feature it needs (the loopback-only `GET /_rystic/activity` aside):

```json
{"feature":"state_set","message":"/_rystic/seed needs the \"state_set\" feature, which this plan does not include; upgrade at https://www.rystic.ai/pricing","upgrade_url":"https://www.rystic.ai/pricing"}
```

Free also refuses these boot inputs, and the twin exits naming the feature: `--scenario` naming any other world (`scenarios`), `--seed` or `RYSTIC_SEED` (`state_set`), and `RYSTIC_NETWORK` (`faults`). See [boot inputs](https://www.rystic.ai/docs/control-plane.md#boot-inputs).

**Pro** opens every control route below and all five worlds. Without `--scenario` the twin boots an **empty** workspace, and `POST /_rystic/reset` wipes it back to empty. An empty workspace has no team and no person, so `issueCreate` answers `Entity not found: Team` and `viewer` is refused until you seed them or load a world.

**Enterprise** is Pro on a license file instead of an API key.

## Auth

Linear reads a personal API key from the bare `Authorization` header, and an OAuth token as `Authorization: Bearer <token>`. The twin accepts any value in either shape; no Linear account or key is involved. `viewer` is the workspace's own person — the one seeded with `isMe: true`, else the first — for every key. The twin does not refuse a request without the header, so it cannot test your missing-key path.

## Coverage

58 GraphQL operations — 32 queries and 26 mutations — on one route, `POST /graphql`. `GET /_rystic/routes` returns that route from a running twin.

| Resource | Queries | Mutations |
|---|---|---|
| Issues | `issue`, `issues`, `searchIssues` | `issueCreate`, `issueUpdate`, `issueArchive`, `issueUnarchive`, `issueDelete` |
| Issue labels | `issueLabel`, `issueLabels` | `issueLabelCreate`, `issueLabelDelete` |
| Issue relations | `issueRelation`, `issueRelations` | `issueRelationCreate`, `issueRelationDelete` |
| Comments | `comment`, `comments` | `commentCreate`, `commentUpdate`, `commentDelete` |
| Projects | `project`, `projects`, `searchProjects` | `projectCreate`, `projectUpdate`, `projectDelete` |
| Project milestones | `projectMilestone`, `projectMilestones` | `projectMilestoneCreate`, `projectMilestoneUpdate`, `projectMilestoneDelete` |
| Project updates | `projectUpdate`, `projectUpdates` | `projectUpdateCreate`, `projectUpdateArchive` |
| Documents | `document`, `documents`, `searchDocuments` | `documentCreate`, `documentUpdate`, `documentDelete` |
| Attachments | `attachment`, `attachments`, `attachmentsForURL` | `attachmentCreate`, `attachmentDelete`, `attachmentLinkURL` |
| Teams, users, workflow states, cycles, organization | `team`, `teams`, `user`, `users`, `viewer`, `workflowState`, `workflowStates`, `cycle`, `cycles`, `organization` | — (a world or a seed holds them) |

An issue read resolves its `team`, `state`, `assignee` and `comments`; a comment or attachment its `issue`; a label its `team`; a relation its `issue` and `relatedIssue`; a team its `issues` and `states`; a milestone, update or document its `project`.

Not modelled, on purpose: workspace administration, integrations, OAuth applications, webhooks, billing, initiatives. A field outside the table answers `400` with `GRAPHQL_VALIDATION_FAILED`.

## Control plane

Every route under `/_rystic/`, and the plan that reaches it. [Routes by plan](https://www.rystic.ai/docs/control-plane.md#routes-by-plan) covers the whole control plane.

| Route | What it does | Plan (feature) |
|---|---|---|
| `GET /_rystic/version` | health check: product, version, model hash, edition, `auth_scheme`, `initial_clock` | every plan |
| `GET /_rystic/routes` | the Linear route this twin serves (`POST /graphql`); not the control plane | every plan |
| `POST /_rystic/reset` | Free: back to `linear_sprint_board`. Pro, Enterprise: wipe every record, rewind the id sequence and the clock | every plan |
| `GET /_rystic/scenarios` | the shipped worlds | Pro, Enterprise (`scenarios`) |
| `POST /_rystic/scenario` | `{"name":"linear_triage_inbox"}`: reset, then load the world | Pro, Enterprise (`scenarios`) |
| `GET /_rystic/inspect` | dump the entire state | Pro, Enterprise (`state_view`) |
| `GET /_rystic/registers` | every seedable register and its fields | Pro, Enterprise (`state_view`) |
| `GET /_rystic/egress` | the outbound effect log; the twin sends no webhooks, so it is always empty | Pro, Enterprise (`state_view`) |
| `POST /_rystic/seed` | land a register map | Pro, Enterprise (`state_set`) |
| `POST /_rystic/clock` | `{"time":"<RFC3339>"}` pins the clock, `{"advance_seconds":N}` moves it | Pro, Enterprise (`state_set`) |
| `POST /_rystic/seed-rng` | `{"seed":N}` fixes the RNG | Pro, Enterprise (`state_set`) |
| `GET`, `POST /_rystic/network` | read or set a network profile: rejected requests and latency ([fault injection](https://www.rystic.ai/docs/control-plane.md#network-profiles-and-fault-injection)) | Pro, Enterprise (`faults`) |
| `GET /_rystic/ui` | the state UI | Pro, Enterprise (`ui`) |
| `GET /_rystic/activity` | the usage counters the CLI reads; any other caller gets 403 | every plan, loopback callers only |

## Scenarios

Five synthetic workspaces ship. `linear_sprint_board` is the one Free boots.

| World | What is in it |
|---|---|
| `linear_sprint_board` | an Engineering team (`ENG`) on the day cycle 7 closes: six people, six workflow states, cycles 6 and 7, eight issues `ENG-101`–`ENG-108`, three labels, two relations, three comments, two attachments |
| `linear_triage_inbox` | a support team's queue: five issues `SUP-301`–`SUP-305` in a Triage state, unassigned and unprioritised, each with its intake attachment |
| `linear_roadmap_quarter` | planning only: two teams, three projects, six milestones in every status, four project updates from onTrack to offTrack, two documents, no issues |
| `linear_solo_workspace` | the floor: one admin, one team (`JUN`), four workflow states, nothing else |
| `linear_archive_vault` | soft-deleted records: an archived issue, an archived and trashed one, a trashed project, archived and trashed documents, a deactivated member |

In the worlds, issues carry no labels and no cycle, and a live issue's `archivedAt` reads `""` rather than null.

To list the worlds: `GET /_rystic/scenarios` (Pro); `rystic docs linear-twin scenarios` prints the archive's `SCENARIOS.md` (the container image does not carry it); `--list-scenarios` on the installed binary works on every plan without a key:

```bash
~/.rystic/twins/linear-twin/0.0.1/linear-twin --list-scenarios
```

## Seeding state

Pro and Enterprise. `POST /_rystic/seed` takes a register map and lands it directly: `state_team`, `state_user`, `state_workflow_state`, `state_cycle` and `state_organization` hold what has no create in the twin's scope, and `state_issue`, `state_comment`, `state_issue_label`, `state_issue_relation`, `state_project`, `state_project_milestone`, `state_project_update`, `state_document` and `state_attachment` the rest. `GET /_rystic/registers` lists every field. A file booted with `--seed <file>` or `RYSTIC_SEED=<file>` wraps the same map in `seed`, beside optional `clock`, `rng_seed` and `network`; a bare register map boots too.

```bash
curl -X POST localhost:8080/_rystic/seed -d '{"state_user":{"user-ada":{"id":"user-ada","name":"Ada Park","email":"ada@example.test","active":true}}}'
curl -s localhost:8080/graphql -H 'Authorization: x' -H 'Content-Type: application/json' -d '{"query":"{ users { nodes { id name } } }"}'
# -> {"data":{"users":{"nodes":[{"id":"user-ada","name":"Ada Park"}]}}}
```

Success returns 204. Only an unknown register or a record that is not a JSON object is refused (400, and an unknown register's error names every valid one).

## Determinism & reset

Execution is serialized. New ids are UUID-shaped counters per family (`00000001-0000-4000-8001-000000000001`), issue numbers count per team, and the clock starts at a pinned instant — `2026-01-01T00:00:00Z` empty, the world's own instant in a world (`2026-02-02T00:00:00Z` in `linear_sprint_board`) — and moves one millisecond per write, never by wall time. Two identical runs from the same reset give byte-identical issues, comments and labels, down to `id`, `identifier`, `number` and `createdAt`.

1. **Free:** `POST /_rystic/reset` re-applies `linear_sprint_board`, clock and id sequence included.
2. **Pro, Enterprise:** reset wipes to empty and rewinds the sequence and the clock; `POST /_rystic/clock` and `POST /_rystic/seed-rng` pin the rest.

State is in memory: a restart boots the plan's world again — `linear_sprint_board` on Free; on Pro and Enterprise whatever `--scenario` or `--seed` names, or an empty workspace.

## Egress

None. The twin delivers no webhooks, and `GET /_rystic/egress` is always empty.

## Limitations

1. A non-null field the twin does not answer fails the whole response with `Cannot return null for non-nullable field …`: `Issue.priorityLabel` and `branchName`; the `issueCreate` payload's `url`, and its `state` when filed without a `stateId`; `Project.state`, `status`, `progress` and `url`; `ProjectUpdate.user`; `Document.url`; `Cycle.isActive` and `progress`; `User.url` and `viewer.organization`; and `Team.displayName` and `private` in the shipped worlds. Leave them out of your selections.
2. `issue.labels`, `issue.attachments`, `issue.relations`, `team.members` and `project.teams` answer empty, and `comment.user` answers null. Read `labelIds`, and list `attachments` and `issueRelations` on their own.
3. A new issue without a `stateId` has no workflow state, where Linear uses the team's default; pass `stateId`.
4. A new issue in a world is numbered from 1 (`ENG-1`, not `ENG-109`).
5. URLs are placeholders: `issue.url` reads `https://linear.app/issue/<identifier>`, with no workspace or title slug.
6. `issues` ignores `filter` (archived issues appear only under `includeArchived: true`); `searchIssues(term:)` matches title and description text.
7. `issue(id:)` takes the UUID only, not the `ENG-103` identifier; `__schema` introspection is refused.
8. No authentication refusal and no webhooks; the twin never makes an outbound call.

Fidelity is scored differentially against the real Linear API, per response field: the last full-suite round matched **64 of 71 probes on 2026-10-01**, with 2 more inconclusive, on a 73-probe suite. Each release states its own number. Items 1–7 were found by driving the twin as an agent would: read the score as the fidelity of what the probes select, not of every field a client can ask for.

The full guide ships in the release as `HOWTO.md`; `rystic docs linear-twin` prints it.

## Release notes

Newest first.

### v0.0.1 — 2026-10-01

1. **A local Linear for your agent's tests.** The first release of the Linear
   twin: 58 GraphQL operations on Linear's one endpoint, `POST /graphql` — issues
   (create, update, archive, unarchive, delete, search), comments, labels,
   relations, projects with milestones and status updates, documents and
   attachments, and read-only teams, users, workflow states, cycles and the
   organization — served from a model validated against the real Linear API.
   Point your client's API URL at `http://localhost:8080/graphql` (`apiUrl` in
   `@linear/sdk`) and your existing queries and mutations run against it
   unchanged. (RYS-1619)

2. **Five named workspaces, and the one your test needs.** `--scenario
   linear_sprint_board` boots an engineering team on the day cycle 7 closes — six
   people, six workflow states, three labels, eight issues with comments,
   relations and attachments — and is the world Free boots. `linear_triage_inbox`
   holds five untriaged support issues, `linear_roadmap_quarter` three projects
   with milestones and status updates, `linear_solo_workspace` one person and one
   team, and `linear_archive_vault` archived and trashed records; `SCENARIOS.md`
   says what each is for. On Pro and Enterprise, `POST /_rystic/seed` lands
   teams, people, workflow states, cycles and any issue, comment, label, project
   or document directly. `POST /_rystic/reset` rewinds the id sequence and the
   clock, so two identical runs give byte-identical issues down to `id`,
   `identifier`, `number` and `createdAt`. (RYS-1619)

3. **It never reaches the network, and it needs no key.** Any `Authorization`
   value is accepted, as a bare API key or a `Bearer` token, and the twin dials
   nothing: no test issue lands in a real workspace and no webhook fires. Not in
   this release, on purpose: workspace administration (creating teams, inviting
   users, workflow and cycle settings), integrations, OAuth applications,
   webhooks, billing and initiatives. A query for any of them answers
   `GRAPHQL_VALIDATION_FAILED` rather than empty data. (RYS-1619)

4. **What is measured, and what is not.** Fidelity is scored differentially
   against the real Linear API, per response field, not by eyeball: the last
   full-suite round matched **64 of 71 probes on 2026-10-01**, with 2 more
   inconclusive, on a 73-probe suite. That is an early number and this release
   ships it as-is. An issue read resolves its team, workflow state, assignee and
   comments, but driving the twin as an agent would found differences the score
   does not show, listed in `HOWTO.md` under Limitations: some non-null fields
   (`Issue.priorityLabel`, `Issue.branchName`, `Project.state`) fail the whole
   response; `issue.labels` and `issue.attachments` answer empty; a new issue
   without a `stateId` has no state; a new issue in a world is numbered from 1;
   `issues` ignores `filter`; and `__schema` introspection is refused. Each
   release states its own number. (RYS-1619)

---

Source: https://www.rystic.ai/docs/simulators/linear · Markdown: https://www.rystic.ai/docs/simulators/linear.md
