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.

  • Integrations
  • $linear-api
  • 183 lines
  • 2 files
  • ~2,000 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- linear-api

Installs to ~/.agents/skills/linear-api/ for every project on this machine.

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

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.

~/.agents/skills/linear-api/SKILL.md

--- name: linear-api description: Automates Linear through its GraphQL API and the @linear/sdk TypeScript client - API keys versus OAuth apps, filtering and cursor pagination,… license: MIT ---

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

  1. Read AGENTS.md; look for an existing @linear/sdk client or raw GraphQL calls and reuse their auth handling.
  2. Pick the credential:
UseCredentialHeader
Personal scripts, internal one-off jobsPersonal API keyAuthorization: <key> (no Bearer)
Integration used by a team or installed by other workspacesOAuth2 appAuthorization: Bearer <token>
Bot that should appear as itself, not a personOAuth with actor=appBearer token; actions are attributed to the app
  1. Secrets in env vars: LINEAR_API_KEY or OAuth client credentials, and LINEAR_WEBHOOK_SECRET. Never log them.
  2. 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 type values: 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, and and / or combinations; date fields accept ISO timestamps.
  • Archived items are excluded unless you pass includeArchived: true.

Rate limits

CredentialRequests per hourComplexity points per hour
API key (pooled per user)2,5003,000,000
OAuth app5,0002,000,000
  • Read X-RateLimit-Requests-Remaining, X-RateLimit-Complexity-Remaining and the matching -Reset headers; X-Complexity shows what the last query cost.
  • Complexity grows with page size times nested fields. Prefer first: 50-100 with narrow selections over huge pages with deep nesting.
  • Rate-limited responses carry a RATELIMITED error 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 Bearer fails 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 parentId for sub-issues; use issueRelationCreate for 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.yamlgenerated from this page

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.

esc