Linear API skill for Codex
Sync issues into Linear without duplicates or loops. Codex automates Linear with the right credential for the job, raw GraphQL for lists so lazy SDK relations do not cause request storms, and cursor pagination that stays inside the hourly complexity budget. Issue creation is deduplicated by external ID or attachment URL, webhooks are checked against Linear-Signature and a 60-second timestamp window, and two-way syncs get a loop breaker.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- linear-api Installs to ~/.agents/skills/linear-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install linear-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project linear-api Installs to .agents/skills/linear-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/linear-api.zipunzip -o linear-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) linear-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $linear-api, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Automates Linear through its GraphQL API and the @linear/sdk TypeScript client - API keys versus OAuth apps, filtering and cursor pagination, creating and updating issues, projects, comments and attachments, complexity-based rate limits, and webhooks verified with Linear-Signature. Use when syncing issues between Linear and another system, creating Linear issues from alerts, support tickets or Git events, building a Linear integration or bot, bulk-updating issues, or receiving Linear webhooks.
Use it when
- Creating or updating Linear issues from another system
- Building a two-way sync between Linear and a helpdesk, Git host or tracker
- Receiving Linear webhooks for issues, comments or projects
- Scripts hit Linear's request or complexity rate limits
Use something else when
- The tracker is Jira: use jira-automation
- You are wiring a no-code Zap or Make scenario rather than code: use zapier-make
How to check the result
A list query matches the issue count in the Linear UI, creating with the same external ID twice yields one issue, and webhooks with a bad signature or stale timestamp are rejected.
The mistake it prevents
Looping over SDK issues and awaiting issue.state or issue.assignee, which fires one request per relation and burns the hourly rate limit in minutes.
Done well means: the integration uses the right credential (personal key for scripts, OAuth app for anything other people install), queries ask only for the fields they need, lists paginate to completion under the complexity budget, writes are idempotent so retries never duplicate issues, and webhooks are verified on the raw body with a timestamp check.
Before writing code
- Read
AGENTS.md; look for an existing@linear/sdkclient or raw GraphQL calls and reuse their auth handling. - Pick the credential:
| Use | Credential | Header |
|---|---|---|
| Personal scripts, internal one-off jobs | Personal API key | Authorization: <key> (no Bearer) |
| Integration used by a team or installed by other workspaces | OAuth2 app | Authorization: Bearer <token> |
| Bot that should appear as itself, not a person | OAuth with actor=app | Bearer token; actions are attributed to the app |
- Secrets in env vars:
LINEAR_API_KEYor OAuth client credentials, andLINEAR_WEBHOOK_SECRET. Never log them. - Bulk updates, deleting or archiving issues, and posting comments that notify people need explicit user confirmation, with a dry-run list shown first.
API calls go to api.linear.app and need network access; in a Codex sandbox, ask for approval
or test against recorded responses.
SDK basics
import { LinearClient } from "@linear/sdk";
const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY }); // or { accessToken }
const team = (await linear.teams({ filter: { key: { eq: "ENG" } } })).nodes[0];
// Create an issue
const created = await linear.createIssue({
teamId: team.id,
title: "Checkout latency above SLO",
description: "p95 1.8 s for 15 min.\n\n[Dashboard](https://grafana.example.com/d/abc)", // Markdown
priority: 2, // 0 none, 1 urgent, 2 high, 3 medium, 4 low
labelIds: [incidentLabelId],
});
const issue = await created.issue; // relations are lazy promises in the SDK
console.log(issue?.identifier, issue?.url);
// Update by identifier
const existing = await linear.issue("ENG-123");
await existing.update({ stateId: doneStateId });
Relations on SDK models (issue.state, issue.assignee, issue.team) are fetched lazily, one
request each. In loops this causes N+1 request storms; use a raw GraphQL query that selects the
nested fields instead.
Raw GraphQL for lists
const query = /* GraphQL */ `
query OpenIssues($after: String) {
issues(
first: 100, after: $after,
filter: { team: { key: { eq: "ENG" } }, state: { type: { in: ["unstarted", "started"] } } }
) {
nodes { id identifier title priority updatedAt state { name } assignee { email } }
pageInfo { hasNextPage endCursor }
}
}`;
async function* openIssues() {
let after: string | null = null;
do {
const res = await linear.client.rawRequest(query, { after });
const page = (res.data as any).issues;
yield* page.nodes;
after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
} while (after);
}
- Workflow state
typevalues:triage,backlog,unstarted,started,completed,canceled. Filter on type, not on state names, which differ per team. - Filters support
eq,neq,in,nin,gt,lt,contains,null, andand/orcombinations; date fields accept ISO timestamps. - Archived items are excluded unless you pass
includeArchived: true.
Rate limits
| Credential | Requests per hour | Complexity points per hour |
|---|---|---|
| API key (pooled per user) | 2,500 | 3,000,000 |
| OAuth app | 5,000 | 2,000,000 |
- Read
X-RateLimit-Requests-Remaining,X-RateLimit-Complexity-Remainingand the matching-Resetheaders;X-Complexityshows what the last query cost. - Complexity grows with page size times nested fields. Prefer
first: 50-100with narrow selections over huge pages with deep nesting. - Rate-limited responses carry a
RATELIMITEDerror code; wait until the reset time instead of retrying immediately. - For sync jobs, use webhooks for changes and a periodic
updatedAt: { gt: lastSync }query to catch anything missed, rather than polling everything.
Idempotent writes
Linear has no idempotency-key header. Make creates safe to retry:
- Store the external ID to Linear ID mapping (alert ID, ticket ID) and look it up before creating.
- Or attach the source URL with
linear.createAttachment({ issueId, url, title })and search by attachment URL before creating; one URL per issue is a natural dedupe key. - Bulk changes: fetch targets, print a dry-run table, apply after confirmation, serially with a short delay, logging each identifier changed.
Webhooks
Create webhooks in workspace settings (API section) or via the webhookCreate mutation,
scoped to a team or all public teams, with the resource types you need (Issue, Comment,
Project, and so on).
import crypto from "node:crypto";
app.post("/webhooks/linear", express.raw({ type: "application/json" }), async (req, res) => {
const expected = crypto.createHmac("sha256", process.env.LINEAR_WEBHOOK_SECRET!).update(req.body).digest("hex");
const got = req.header("linear-signature") ?? "";
if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString());
if (Math.abs(Date.now() - payload.webhookTimestamp) > 60_000) return res.sendStatus(401); // replay guard
res.sendStatus(200); // within 5 s
if (await seen(req.header("linear-delivery"))) return;
if (payload.type === "Issue" && payload.action === "update" && payload.updatedFrom?.stateId) {
await onStateChange(payload.data, payload.updatedFrom.stateId);
}
});
- Payload fields:
action(create,update,remove),type,data,updatedFrom(previous values of changed fields on updates),url,webhookTimestamp(milliseconds). - A delivery fails on non-200 or after 5 seconds; Linear retries up to 3 times (after 1 minute, 1 hour and 6 hours) and may disable a persistently failing webhook.
- The SDK also ships a webhook verification helper; either way, verify the raw bytes, not re-serialised JSON.
- Ignore events caused by your own integration (compare the actor or the app user ID) to avoid sync loops.
Gotchas
- Sending a personal API key with
Bearerfails authentication; OAuth tokens need it. - Team-specific IDs: state, label and cycle IDs differ per team. Resolve them by name or type at startup and cache them, never hard-code IDs from another workspace.
- Descriptions are Markdown; mentions and issue links are resolved by Linear when written as URLs or identifiers.
- Sub-issues and relations: set
parentIdfor sub-issues; useissueRelationCreatefor blocks/related links. - Two-way sync needs a loop breaker (origin marker or last-writer timestamp per field) or the systems echo changes forever.
Verification
- Run the list query against one team and compare the count with the Linear UI filter.
- Create an issue twice with the same external ID: only one exists.
- Send a webhook with a bad signature and one with a stale timestamp: both rejected.
- Log the remaining complexity after a full sync; it should stay well above zero.
Report template
**Auth:** OAuth app (actor=app), scopes read, write
**Flow:** PagerDuty incident -> create ENG issue (priority from severity), dedupe by attachment URL
**Webhooks:** /webhooks/linear, Issue + Comment, signature and 60 s timestamp checked
**Sync:** state changes mirrored back to incident notes; loop guard on app user ID
**Limits:** query cost ~40 points; hourly reconcile with updatedAt filter
**Needs user:** webhook creation in workspace settings, bulk backfill approval What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Linear API"
short_description: "Sync issues into Linear without duplicates or loops"
default_prompt: "$linear-api Create a Linear issue in the ENG team whenever a PagerDuty incident fires, without duplicates" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.