CtrlK
BlogDocsLog inGet started
Tessl Logo

linear-graphql

Call the Linear GraphQL API from agent sessions in this repo — authenticate via LINEAR_API_KEY loaded from .env.local without tripping the Freebuff env guard, use shorthand issue IDs, avoid the IssueFilter/ID-typing schema traps, and flip issues or query state with scripts/linear-flip-done.mjs.

64

Quality

78%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Fix and improve this skill with Tessl

tessl review fix ./.agents/skills/linear-graphql/SKILL.md
SKILL.md
Quality
Evals
Security

Linear GraphQL API from agent sessions

Use this skill whenever a task needs to read or mutate Linear data (issues, states, projects) from a terminal session in this repo: closing issues after a fix lands, filing follow-ups, checking tracker state.

Knowledge

  • Endpoint: https://api.linear.app/graphql (introspectable; browsable in Apollo Studio via linear.app/developers/graphql).

  • Auth: personal API key passed raw — Authorization: <API_KEY>, no Bearer prefix. (OAuth2 uses Bearer, API keys do not — verified against Linear's official docs, 2026-09.)

  • Where the key lives: Freebuff Settings → Environment (Keys tab) as LINEAR_API_KEY, merged into the gitignored .env.local.

  • Agent shells do NOT inherit Settings → Environment values. echo ${LINEAR_API_KEY:+SET} prints nothing even when the key is configured. Expected, not a missing key.

  • The Freebuff workspace guard blocks any command line containing a raw secret value ("Direct env and sensitive-file access is blocked"). It also blocks shell sourcing (. ./.env.local) and node --env-file=.env.local because those reference the env file from the command line. So the key must be loaded from inside the script itself.

  • Schema traps (all verified live):

    1. IssueFilter has no identifier fieldissues(filter: { identifier: { in: [...] } }) fails with Field "identifier" is not defined by type "IssueFilter". Use issue(id: "VAU-16") per issue instead; issue and issueUpdate accept shorthand human IDs (VAU-16) as well as UUIDs.
    2. Variables the schema types as ID (e.g. team id in workflowStates(filter: { team: { id: { eq: $team } } })) must be declared ID! in the query document — String! fails with a variable-type mismatch.
    3. GraphQL returns HTTP 200 even for partial failures — always check the errors array before assuming success.
  • Rate limits: batch independent lookups with Promise.all, don't poll per-issue; Linear documents this explicitly.

  • Repo tooling:

    ScriptDirectionAuth source
    scripts/linear-sync.js pullLinear → docs/LINEAR_MIRROR.mdenv key (repo secret in CI)
    scripts/linear-sync.js push-githubGitHub issues → Linearenv key + gh CLI
    scripts/linear-flip-done.mjsflip issues to Done by identifierenv key → local dotenv fallback

    linear-flip-done.mjs landed via PR #92. If it's absent from your checkout, create it from the loader + gql wrapper in the Instructions below.

Instructions

1. Ensure the key exists

If .env.local already contains LINEAR_API_KEY (it does as of 2026-09), skip this step. Otherwise ask the user to paste the key and merge it with:

freebuff-env set --file .env.local '{"LINEAR_API_KEY":"<user-pasted-key>"}'

Only pass values the user explicitly supplied. Never echo, log, or commit the value; never put it in a shell command, PR body, or commit message.

2. Reuse the existing helper for the common case

node scripts/linear-flip-done.mjs VAU-10 VAU-12   # flip issues to Done

The script reads the key from the process environment, falling back to a dotenv-style parse of the local env file inside the script (the guard-safe pattern). It resolves the team's completed state by type, so it works even if your team renamed "Done".

3. For new operations, copy the loader + gql wrapper

import { readFileSync } from 'node:fs';

if (!process.env.LINEAR_API_KEY) {
  try {
    for (const line of readFileSync('.env.local', 'utf8').split('\n')) {
      // Handles: KEY=value | export KEY=value | KEY="value" | inline comments.
      const m = line.match(
        /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:(['"])((?:\\.|(?!\2)[\s\S])*)\2|((?:\\.|[^#])*?))\s*(?:#.*)?$/
      );
      if (m && process.env[m[1]] === undefined) {
        const raw = m[3] ?? (m[4] || '').trim();
        process.env[m[1]] = raw.replace(/\\([nrt\\'"#])/g, (_, e) =>
          ({ n: '\n', r: '\r', t: '\t' }[e] ?? e)
        );
      }
    }
  } catch { /* absent file — surface the error below */ }
}

const KEY = process.env.LINEAR_API_KEY;

function gql(query, variables = {}) {
  return fetch('https://api.linear.app/graphql', {
    method: 'POST',
    headers: { Authorization: KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  }).then(async (r) => {
    const json = await r.json();
    if (json.errors) throw new Error(json.errors.map((e) => e.message).join('; '));
    return json.data;
  });
}

// Single issue by shorthand id — returns data.issue directly.
const { issue } = await gql(
  `query($id: String!) { issue(id: $id) { id identifier title state { id name } team { id } } }`,
  { id: 'VAU-16' }
);

// Resolve the team's completed state (note: $team is ID!, not String!).
// Filter by the state's *type* rather than its literal name — teams can
// rename "Done" to anything ("Shipped", "Complete"), but the completed
// category is stable.
const teamId = issue.team.id;
const { workflowStates } = await gql(
  `query($team: ID!) {
    workflowStates(filter: { team: { id: { eq: $team } }, type: { eq: "completed" } }) {
      nodes { id name type }
    }
  }`,
  { team: teamId }
);
if (workflowStates.nodes.length === 0) {
  throw new Error(`No completed workflow state found for team ${teamId}`);
}
const doneState = workflowStates.nodes[0];

// Update by shorthand id or uuid — both accepted.
await gql(
  `mutation($id: String!, $stateId: String!) {
    issueUpdate(id: $id, input: { stateId: $stateId }) { success }
  }`,
  { id: issue.id, stateId: doneState.id }
);

Remember: gql returns the data object ({ issue: {...} }) — unwrap the root field before use.

4. Housekeeping

  • docs/LINEAR_MIRROR.md is a generated snapshot refreshed by the nightly linear-mirror.yml workflow. Don't fight the env-file guard to regenerate it locally; let CI do it.
  • Keep .env.local untracked (gitignored). Never git add -f it.
  • When Linear states are flipped alongside a code change, note it in the PR description so a review rejection reverts the state too.
Repository
pvnkmnk/vault-memory
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.