Claude-code-plugins linear-sdk-patterns
install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/linear-pack/skills/linear-sdk-patterns" ~/.claude/skills/jeremylongshore-claude-code-plugins-linear-sdk-patterns && rm -rf "$T"
manifest:
plugins/saas-packs/linear-pack/skills/linear-sdk-patterns/SKILL.mdsource content
Linear SDK Patterns
Overview
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.
Prerequisites
installed@linear/sdk- TypeScript project with
strict: true - Understanding of async/await and GraphQL concepts
Instructions
Pattern 1: Client Singleton
import { 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)!; }
Pattern 2: Cursor-Based Pagination
Linear uses Relay-style cursor pagination. The SDK provides
fetchNext() and fetchPrevious() helpers, plus raw pageInfo for manual control.
// 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}`); }
Pattern 3: Relation Loading (Avoiding N+1)
SDK models lazy-load relations. Accessing
.assignee triggers a separate API call. Use raw GraphQL to batch-fetch relations in one request.
// 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 }; }
Pattern 4: Filtering with Comparators
Linear supports
eq, neq, in, nin, lt, lte, gt, gte, startsWith, contains, and logical and/or operators.
// 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, });
Pattern 5: Type-Safe Error Handling
import { 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); }
Pattern 6: Custom GraphQL Client
Access the underlying
LinearGraphQLClient for full control.
const 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 Handling
| Error | Cause | Solution |
|---|---|---|
| Nullable relation not checked | Use |
| SDK/TypeScript version mismatch | Update to latest |
| Missing try/catch on async | Wrap in or |
| Too many nested relations | Use with flat field selection |
Examples
Create Issue with Full Metadata
const 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, });