Skip to Content

Simulators

Linear simulator

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  (how).

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

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

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:

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

Plans

What linear-twin does on each plan (plans):

FreeProEnterprise
CredentialAPI keyAPI keylicense file
Bootslinear_sprint_boardan empty workspace, or the --scenario you namesame as Pro
POST /_rystic/resetre-applies linear_sprint_boardwipes to emptywipes to empty
Linear GraphQL (all 58 operations)yesyesyes
Control planeversion, routes, reset onlyevery routeevery 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):

{"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.

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.

ResourceQueriesMutations
Issuesissue, issues, searchIssuesissueCreate, issueUpdate, issueArchive, issueUnarchive, issueDelete
Issue labelsissueLabel, issueLabelsissueLabelCreate, issueLabelDelete
Issue relationsissueRelation, issueRelationsissueRelationCreate, issueRelationDelete
Commentscomment, commentscommentCreate, commentUpdate, commentDelete
Projectsproject, projects, searchProjectsprojectCreate, projectUpdate, projectDelete
Project milestonesprojectMilestone, projectMilestonesprojectMilestoneCreate, projectMilestoneUpdate, projectMilestoneDelete
Project updatesprojectUpdate, projectUpdatesprojectUpdateCreate, projectUpdateArchive
Documentsdocument, documents, searchDocumentsdocumentCreate, documentUpdate, documentDelete
Attachmentsattachment, attachments, attachmentsForURLattachmentCreate, attachmentDelete, attachmentLinkURL
Teams, users, workflow states, cycles, organizationteam, 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 covers the whole control plane.

RouteWhat it doesPlan (feature)
GET /_rystic/versionhealth check: product, version, model hash, edition, auth_scheme, initial_clockevery plan
GET /_rystic/routesthe Linear route this twin serves (POST /graphql); not the control planeevery plan
POST /_rystic/resetFree: back to linear_sprint_board. Pro, Enterprise: wipe every record, rewind the id sequence and the clockevery plan
GET /_rystic/scenariosthe shipped worldsPro, Enterprise (scenarios)
POST /_rystic/scenario{"name":"linear_triage_inbox"}: reset, then load the worldPro, Enterprise (scenarios)
GET /_rystic/inspectdump the entire statePro, Enterprise (state_view)
GET /_rystic/registersevery seedable register and its fieldsPro, Enterprise (state_view)
GET /_rystic/egressthe outbound effect log; the twin sends no webhooks, so it is always emptyPro, Enterprise (state_view)
POST /_rystic/seedland a register mapPro, Enterprise (state_set)
POST /_rystic/clock{"time":"<RFC3339>"} pins the clock, {"advance_seconds":N} moves itPro, Enterprise (state_set)
POST /_rystic/seed-rng{"seed":N} fixes the RNGPro, Enterprise (state_set)
GET, POST /_rystic/networkread or set a network profile: rejected requests and latency (fault injection)Pro, Enterprise (faults)
GET /_rystic/uithe state UIPro, Enterprise (ui)
GET /_rystic/activitythe usage counters the CLI reads; any other caller gets 403every plan, loopback callers only

Scenarios

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

WorldWhat is in it
linear_sprint_boardan 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_inboxa support team’s queue: five issues SUP-301–SUP-305 in a Triage state, unassigned and unprioritised, each with its intake attachment
linear_roadmap_quarterplanning only: two teams, three projects, six milestones in every status, four project updates from onTrack to offTrack, two documents, no issues
linear_solo_workspacethe floor: one admin, one team (JUN), four workflow states, nothing else
linear_archive_vaultsoft-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:

~/.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.

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)

Last updated on