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.
82%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
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.
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):
IssueFilter has no identifier field — issues(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.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.errors array before assuming success.Rate limits: batch independent lookups with Promise.all, don't poll
per-issue; Linear documents this explicitly.
Repo tooling:
| Script | Direction | Auth source |
|---|---|---|
scripts/linear-sync.js pull | Linear → docs/LINEAR_MIRROR.md | env key (repo secret in CI) |
scripts/linear-sync.js push-github | GitHub issues → Linear | env key + gh CLI |
scripts/linear-flip-done.mjs | flip issues to Done by identifier | env 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.
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.
node scripts/linear-flip-done.mjs VAU-10 VAU-12 # flip issues to DoneThe 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".
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.
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..env.local untracked (gitignored). Never git add -f it.pvnkmnk/vault-memory
f7f6289
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.