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):
| 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):
{"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.
| 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 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) | 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:
~/.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.
- Free:
POST /_rystic/resetre-applieslinear_sprint_board, clock and id sequence included. - Pro, Enterprise: reset wipes to empty and rewinds the sequence and the clock;
POST /_rystic/clockandPOST /_rystic/seed-rngpin 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
- A non-null field the twin does not answer fails the whole response with
Cannot return null for non-nullable field …:Issue.priorityLabelandbranchName; theissueCreatepayload’surl, and itsstatewhen filed without astateId;Project.state,status,progressandurl;ProjectUpdate.user;Document.url;Cycle.isActiveandprogress;User.urlandviewer.organization; andTeam.displayNameandprivatein the shipped worlds. Leave them out of your selections. issue.labels,issue.attachments,issue.relations,team.membersandproject.teamsanswer empty, andcomment.useranswers null. ReadlabelIds, and listattachmentsandissueRelationson their own.- A new issue without a
stateIdhas no workflow state, where Linear uses the team’s default; passstateId. - A new issue in a world is numbered from 1 (
ENG-1, notENG-109). - URLs are placeholders:
issue.urlreadshttps://linear.app/issue/<identifier>, with no workspace or title slug. issuesignoresfilter(archived issues appear only underincludeArchived: true);searchIssues(term:)matches title and description text.issue(id:)takes the UUID only, not theENG-103identifier;__schemaintrospection is refused.- 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
-
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 athttp://localhost:8080/graphql(apiUrlin@linear/sdk) and your existing queries and mutations run against it unchanged. (RYS-1619) -
Five named workspaces, and the one your test needs.
--scenario linear_sprint_boardboots 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_inboxholds five untriaged support issues,linear_roadmap_quarterthree projects with milestones and status updates,linear_solo_workspaceone person and one team, andlinear_archive_vaultarchived and trashed records;SCENARIOS.mdsays what each is for. On Pro and Enterprise,POST /_rystic/seedlands teams, people, workflow states, cycles and any issue, comment, label, project or document directly.POST /_rystic/resetrewinds the id sequence and the clock, so two identical runs give byte-identical issues down toid,identifier,numberandcreatedAt. (RYS-1619) -
It never reaches the network, and it needs no key. Any
Authorizationvalue is accepted, as a bare API key or aBearertoken, 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 answersGRAPHQL_VALIDATION_FAILEDrather than empty data. (RYS-1619) -
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.mdunder Limitations: some non-null fields (Issue.priorityLabel,Issue.branchName,Project.state) fail the whole response;issue.labelsandissue.attachmentsanswer empty; a new issue without astateIdhas no state; a new issue in a world is numbered from 1;issuesignoresfilter; and__schemaintrospection is refused. Each release states its own number. (RYS-1619)