Agent Skills: Linear SDK Patterns

|

UncategorizedID: jeremylongshore/claude-code-plugins-plus-skills/linear-sdk-patterns

Install this agent skill to your local

pnpm dlx add-skill https://github.com/jeremylongshore/claude-code-plugins-plus-skills/tree/HEAD/plugins/saas-packs/linear-pack/skills/linear-sdk-patterns

Skill Files

Browse the full folder contents for linear-sdk-patterns.

Download Skill

Loading file tree…

plugins/saas-packs/linear-pack/skills/linear-sdk-patterns/SKILL.md

Skill Metadata

Name
linear-sdk-patterns
Description
|

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

  • @linear/sdk installed
  • 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 | |-------|-------|----------| | 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 |

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,
});

Resources