Skip to main content
The Linear template (apps/linear-clone, name linear) is an API-only clone of the Linear GraphQL API. Unlike the other templates, Linear is not REST — its public API is a single GraphQL endpoint, and the clone mirrors that exactly: one POST /graphql that accepts { query, variables, operationName }, returns the GraphQL { data, errors } transport envelope, dispatches Linear’s literal camelCase operation names (issueCreate, issues, teamCreate, …), and paginates with Relay connections. It’s faithful enough at the shape level that an agent built for Linear can operate it.
asymmetric spin linear

What’s inside

PieceNotes
backend/NestJS backend on Postgres via pg. Data plane is POST /graphql; a small REST control plane lives under /api. JWT (access + refresh) + Linear-style lin_api_… personal API keys, Socket.io realtime. Health at /api/health, Swagger (control plane only) at /api/docs.
mcp-server/Model Context Protocol server for AI interactions.
supabase/migrations/001_initial_schema.sql — 15 tables + triggers, run on spin and replayed on reset.
seeds/acme.sql — the bundled deterministic, idempotent demo fixture.
This template is API-only — there is no frontend.

Modes

ModeServicesUse it for
api (default)backendAgents that talk to the GraphQL API or MCP server.

Seeding

asymmetric spin linear --seed acme           # deterministic fixture on create
asymmetric seed linear-a1b2 --ai             # realistic data via the API
Seeded users share the password password123 (e.g. dana@acme.test). AI seeding generates users, teams, issues, and projects (the template’s declared entities). See Seeding.

Tokens and auth

Two credential types both resolve to the same principal:
  • a JWT access token from POST /api/auth/signup / login / refresh (the web/control-plane path), and
  • a Linear-shaped personal API key (lin_api_…) minted at POST /api/api-keys. Only sha256(key) is stored; the plaintext is returned in the token field once at creation.
Against the GraphQL endpoint, send the personal API key raw in the Authorization header (no Bearer prefix) — this is exactly how Linear authenticates a personal API key. An OAuth-style Bearer token is also accepted.
B=http://127.0.0.1:3002
# Control plane (REST): sign up, create an org (token is refreshed to carry the
# org claim), then mint a personal API key.
TOK=$(curl -s $B/api/auth/signup -H 'content-type: application/json' \
  -d '{"email":"you@acme.test","password":"password123","name":"You"}' | jq -r .accessToken)
TOK=$(curl -s $B/api/organization -H "authorization: Bearer $TOK" -H 'content-type: application/json' \
  -d '{"name":"Acme","urlKey":"acme"}' | jq -r .accessToken)
KEY=$(curl -s $B/api/api-keys -H "authorization: Bearer $TOK" -H 'content-type: application/json' \
  -d '{"label":"agent"}' | jq -r .token)
A lin_api_ key authenticates but its scopes are not enforced — any valid key acts with its principal’s full access. OAuth app installs and scope enforcement are out of scope for this slice. Clones bind to localhost by default, so a key is not reachable off your machine unless you --expose.

API tour — it’s GraphQL

Every data-plane operation is a POST /graphql. Mutations return Linear’s { success, <entity>, lastSyncId } payload; queries that return lists are Relay connections (nodes, edges { node, cursor }, pageInfo { hasNextPage, endCursor }) with first / after args.
B=http://127.0.0.1:3002
H="authorization: $KEY"   # raw lin_api_… key, Linear-style

# Create a team (auto-seeds 6 default workflow states)
TEAM=$(curl -s $B/graphql -H "$H" -H 'content-type: application/json' -d '{
  "query":"mutation($input: TeamCreateInput!){ teamCreate(input:$input){ success team{ id key name } } }",
  "variables":{"input":{"key":"ENG","name":"Engineering"}}
}' | jq -r .data.teamCreate.team.id)

# File an issue → human identifier ENG-1 (team key + per-team counter)
curl -s $B/graphql -H "$H" -H 'content-type: application/json' -d "{
  \"query\":\"mutation(\$input: IssueCreateInput!){ issueCreate(input:\$input){ success issue{ identifier title } } }\",
  \"variables\":{\"input\":{\"teamId\":\"$TEAM\",\"title\":\"First bug\",\"priority\":2}}
}" | jq '.data.issueCreate.issue'

# Read issues back through a Relay connection
curl -s $B/graphql -H "$H" -H 'content-type: application/json' -d '{
  "query":"{ issues(first: 10){ nodes{ identifier title } pageInfo{ hasNextPage endCursor } } }"
}' | jq '.data.issues'
Issues are numbered per team and carry a human identifier like ENG-42 (team.key + a monotonic counter). Entity ids are UUIDs, matching Linear.

Realtime

Connect a Socket.io client with auth: { token } (JWT or API key). You’re joined to an org:<id> room and receive every domain event (issue.created, issue.updated, comment.created, project.*, cycle.*).

Webhooks

Register a target with POST /api/webhooks (optionally scoped to a team and to specific resource types). Deliveries are HTTP POSTs signed with a Linear-Signature header = hex HMAC-SHA256(rawBody, secret), carrying Linear’s webhook envelope:
{ "action": "create", "type": "Issue", "actor": {}, "createdAt": "…",
  "data": {}, "url": "…", "updatedFrom": {}, "webhookTimestamp": 0,
  "webhookId": "…", "organizationId": "…" }
updatedFrom is present only on update actions, and url is derived per entity type — matching Linear.

Rate limiting

The GraphQL endpoint enforces Linear’s published budgets per principal: 5,000 requests/hour, 3,000,000 complexity points/hour, and a 10,000-point cap on a single query (complexity is estimated from the selection set). When a budget is exhausted the response is HTTP 400 with errors[].extensions.code = "RATELIMITED", and every response carries X-RateLimit-Requests-* and X-RateLimit-Complexity-* headers (Limit / Remaining / Reset).

API shape — read this before pointing an agent at it

Linear’s real API is GraphQL, and the clone reproduces that paradigm exactly: a single POST /graphql, the { data, errors } transport envelope, Linear’s literal operation names, { success, <entity>, lastSyncId } mutation payloads, and Relay-style connection pagination. So this template matches Linear at the shape level, not by URL paths. The template ships an API_PARITY.md that scores it against Linear’s live GraphQL docs. On the representative core set of 30 Linear operations (issues, comments, projects, teams, labels, workflow states, cycles, org, users/viewer, webhooks):
StatusCount
✅ exact (identifier + GraphQL convention + envelope align)30
⚠️ partial0
❌ missing0
That’s 100% capability and 100% exact-shape coverage of the targeted core surface. Linear’s full schema is hundreds of operations; the long tail (attachments, documents, project updates, reactions, favorites, roadmaps, notifications, team-membership mutations, …) is unimplemented by design — this is a core slice, not the whole schema. See apps/linear-clone/API_PARITY.md for the per-operation breakdown. A small REST control plane stays under the /api prefix for bootstrap that sits outside Linear’s GraphQL surface: auth/{signup,login,refresh,logout}, api-keys, and health.
The verify command will fold live fidelity scoring into the CLI. Until then, API_PARITY.md and the /verify-api linear workflow are how fidelity is tracked.

MCP server

mcp-server/ exposes the clone to MCP clients with a personal API key. It ships a core subset of Linear’s MCP tools: list_teams, list_issues, get_issue, create_issue, update_issue, create_comment, and list_projects.
cd mcp-server && bun install
LINEAR_API_URL=http://127.0.0.1:3002/api LINEAR_API_KEY=lin_api_… bun run dev

Inspect a running clone

asymmetric query linear-a1b2 "select identifier, title from issues"
asymmetric db linear-a1b2          # prints a psql shell command
asymmetric logs linear-a1b2 -f     # stream the backend
The schema (organizations, users, teams, issues, comments, projects, cycles, workflow_states, labels, webhooks, …) comes straight from 001_initial_schema.sql — read it to know what’s queryable.