Install any skill in seconds. Free to start, no credit card required.
Get Started Free →TypeScript/JavaScript SDK patterns and best practices for Linear. Use when learning SDK idioms, implementing pagination, filtering, relation loading, or custom GraphQL queries. Trigger: "linear SDK patterns", "linear best practices", "linear typescript", "linear API patterns", "linear pagination".
.claude/skills/jeremylongshore-linear-sdk-patterns/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 74% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 86% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 195% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 52% | 0% |
| case-16 | ✗→✓ | ▲ Improved | 96% | 0% |
Production patterns for @linear/sdk. The SDK wraps Linear's GraphQL API with strongly-typed models, cursor-based pagination (fetchNext()/fetchPrevious()), lazy-loaded relations, and typed error classes. Understanding these patterns avoids N+1 queries and rate limit waste.
@linear/sdk installedstrict: truetypescriptimport { LinearClient } from "@linear/sdk"; let _client: LinearClient | null = null; export function getLinearClient(): LinearClient { if (!_client) { const apiKey = process.env.LINEAR_API_KEY; if (!apiKey) throw new Error("LINEAR_API_KEY is required"); _client = new LinearClient({ apiKey }); } return _client; } // For multi-user OAuth apps — one client per user const clientCache = new Map<string, LinearClient>(); export function getClientForUser(userId: string, accessToken: string): LinearClient { if (!clientCache.has(userId)) { clientCache.set(userId, new LinearClient({ accessToken })); } return clientCache.get(userId)!; }
Linear uses Relay-style cursor pagination. The SDK provides fetchNext() and fetchPrevious() helpers, plus raw pageInfo for manual control.
typescript// SDK built-in pagination helpers const firstPage = await client.issues({ first: 50 }); console.log(`Page 1: ${firstPage.nodes.length} issues`); if (firstPage.pageInfo.hasNextPage) { const secondPage = await firstPage.fetchNext(); console.log(`Page 2: ${secondPage.nodes.length} issues`); } // Manual pagination with cursor — good for streaming all data async function* paginateAll<T>( fetchPage: (cursor?: string) => Promise<{ nodes: T[]; pageInfo: { hasNextPage: boolean; endCursor: string }; }> ): AsyncGenerator<T> { let cursor: string | undefined; let hasNext = true; while (hasNext) { const page = await fetchPage(cursor); for (const node of page.nodes) yield node; hasNext = page.pageInfo.hasNextPage; cursor = page.pageInfo.endCursor; } } // Stream all issues without loading everything into memory for await (const issue of paginateAll(c => client.issues({ first: 50, after: c }))) { console.log(`${issue.identifier}: ${issue.title}`); }
SDK models lazy-load relations. Accessing .assignee triggers a separate API call. Use raw GraphQL to batch-fetch relations in one request.
typescript// LAZY (N+1 problem) — each .assignee is a separate API call const issues = await client.issues({ first: 50 }); for (const issue of issues.nodes) { const assignee = await issue.assignee; // API call per issue! console.log(`${issue.identifier}: ${assignee?.name}`); } // BATCH (1 request) — use rawRequest for precise field selection const response = await client.client.rawRequest(` query TeamIssues($teamKey: String!) { issues(first: 50, filter: { team: { key: { eq: $teamKey } } }) { nodes { id identifier title priority assignee { name email } state { name type } labels { nodes { name color } } project { name } } } } `, { teamKey: "ENG" }); // PRE-RESOLVE — parallel resolution for a single issue async function enrichIssue(issue: any) { const [assignee, state, team, labels] = await Promise.all([ issue.assignee, issue.state, issue.team, issue.labels(), ]); return { ...issue, _assignee: assignee, _state: state, _team: team, _labels: labels.nodes }; }
Linear supports eq, neq, in, nin, lt, lte, gt, gte, startsWith, contains, and logical and/or operators.
typescript// High-priority open bugs const bugs = await client.issues({ first: 50, filter: { priority: { lte: 2 }, state: { type: { nin: ["completed", "canceled"] } }, labels: { name: { eq: "Bug" } }, team: { key: { eq: "ENG" } }, }, }); // OR logic — issues assigned to Alice or Bob const filtered = await client.issues({ filter: { or: [ { assignee: { email: { eq: "alice@company.com" } } }, { assignee: { email: { eq: "bob@company.com" } } }, ], state: { type: { eq: "started" } }, }, }); // Full-text search const results = await client.issueSearch("authentication bug"); // Issues updated in the last 24 hours const recent = await client.issues({ filter: { updatedAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() }, }, orderBy: "updatedAt", first: 100, });
typescriptimport { LinearError, InvalidInputLinearError } from "@linear/sdk"; type Result<T> = { ok: true; data: T } | { ok: false; error: string; retryable: boolean }; async function safeCall<T>(fn: () => Promise<T>): Promise<Result<T>> { try { return { ok: true, data: await fn() }; } catch (error) { if (error instanceof InvalidInputLinearError) { return { ok: false, error: `Invalid input: ${error.message}`, retryable: false }; } if (error instanceof LinearError) { const retryable = error.status === 429 || error.status === 503; return { ok: false, error: `[${error.status}] ${error.message}`, retryable }; } return { ok: false, error: String(error), retryable: false }; } } // Usage const result = await safeCall(() => client.issue("issue-uuid")); if (result.ok) { console.log(result.data.title); } else if (result.retryable) { console.warn("Transient error, retry:", result.error); }
Access the underlying LinearGraphQLClient for full control.
typescriptconst graphQLClient = client.client; // Set custom headers graphQLClient.setHeader("X-Request-Id", crypto.randomUUID()); // Raw query with variables const data = await graphQLClient.rawRequest(` query Cycle($id: String!) { cycle(id: $id) { id name startsAt endsAt issues { nodes { identifier title state { name } } } } } `, { id: "cycle-uuid" }); // Batch mutations const batchResult = await graphQLClient.rawRequest(` mutation BatchUpdate { a: issueUpdate(id: "id1", input: { priority: 1 }) { success } b: issueUpdate(id: "id2", input: { priority: 1 }) { success } c: issueUpdate(id: "id3", input: { priority: 1 }) { success } } `);
| Error | Cause | Solution | |-------|-------|----------| | Cannot read properties of null | Nullable relation not checked | Use (await issue.assignee)?.name | | Type is not assignable | SDK/TypeScript version mismatch | Update @linear/sdk to latest | | Promise rejection unhandled | Missing try/catch on async | Wrap in safeCall() or .catch() | | Query complexity too high | Too many nested relations | Use rawRequest() with flat field selection |
typescriptconst teams = await client.teams(); const eng = teams.nodes.find(t => t.key === "ENG")!; const states = await eng.states(); const todo = states.nodes.find(s => s.type === "unstarted")!; const labels = await client.issueLabels({ filter: { name: { eq: "Bug" } } }); await client.createIssue({ teamId: eng.id, title: "Login page crashes on Safari", description: "## Steps to reproduce\n1. Open login in Safari 17\n2. Click Sign in\n3. Crash", stateId: todo.id, priority: 1, labelIds: [labels.nodes[0].id], estimate: 3, });
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 25,544 | 19,365 | -24% | 1 | 1 | 0% | 3,221 | 5,595 | +74% | 0 | 0 | — |
case-02 | fail→pass | 24,773 | 22,156 | -11% | 1 | 1 | 0% | 2,900 | 5,384 | +86% | 0 | 0 | — |
case-03 | pass→pass | 18,162 | 17,985 | -1% | 1 | 1 | 0% | 2,458 | 4,868 | +98% | 0 | 0 | — |
case-04 | fail→pass | 12,273 | 7,939 | -35% | 1 | 1 | 0% | 968 | 2,860 | +195% | 0 | 0 | — |
case-05 | pass→pass | 22,391 | 15,456 | -31% | 1 | 1 | 0% | 2,819 | 4,403 | +56% | 0 | 0 | — |
case-06 | pass→pass | 14,241 | 12,111 | -15% | 1 | 1 | 0% | 1,327 | 3,577 | +170% | 0 | 0 | — |
case-07 | fail→fail | 16,585 | 11,505 | -31% | 1 | 1 | 0% | 1,734 | 3,625 | +109% | 0 | 0 | — |
case-08 | pass→pass | 10,371 | 15,837 | +53% | 1 | 1 | 0% | 1,807 | 4,269 | +136% | 0 | 0 | — |
case-09 | pass→pass | 15,005 | 15,865 | +6% | 1 | 1 | 0% | 1,853 | 3,899 | +110% | 0 | 0 | — |
case-10 | pass→pass | 17,913 | 11,725 | -35% | 1 | 1 | 0% | 2,581 | 4,647 | +80% | 0 | 0 | — |
case-11 | pass→pass | 14,696 | 16,604 | +13% | 1 | 1 | 0% | 2,698 | 4,545 | +68% | 0 | 0 | — |
case-12 | pass→pass | 14,419 | 20,463 | +42% | 1 | 1 | 0% | 1,686 | 4,469 | +165% | 0 | 0 | — |
case-13 | pass→pass | 14,348 | 12,071 | -16% | 1 | 1 | 0% | 2,748 | 4,863 | +77% | 0 | 0 | — |
case-14 | pass→pass | 5,657 | 8,577 | +52% | 1 | 1 | 0% | 850 | 2,898 | +241% | 0 | 0 | — |
case-15 | fail→pass | 15,832 | 14,546 | -8% | 1 | 1 | 0% | 2,965 | 4,509 | +52% | 0 | 0 | — |
case-20 | pass→pass | 18,060 | 15,735 | -13% | 1 | 1 | 0% | 2,601 | 4,788 | +84% | 0 | 0 | — |
case-16 | fail→pass | 15,140 | 19,474 | +29% | 1 | 1 | 0% | 2,513 | 4,919 | +96% | 0 | 0 | — |
case-17 | pass→pass | 15,409 | 7,929 | -49% | 1 | 1 | 0% | 2,039 | 3,748 | +84% | 0 | 0 | — |
case-18 | pass→pass | 10,940 | 3,301 | -70% | 1 | 1 | 0% | 1,419 | 2,924 | +106% | 0 | 0 | — |
case-19 | pass→pass | 12,441 | 7,817 | -37% | 1 | 1 | 0% | 2,249 | 3,880 | +73% | 0 | 0 | — |
case-21 | pass→pass | 18,445 | 10,736 | -42% | 1 | 1 | 0% | 2,685 | 4,548 | +69% | 0 | 0 | — |
case-22 | pass→pass | 17,669 | 21,774 | +23% | 1 | 1 | 0% | 2,452 | 4,924 | +101% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +23 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.