From linear
Reference for Linear webhooks and TypeScript SDK. Use when receiving Linear webhook payloads, responding to issues or comments, changing issue status, or querying Linear state via the @linear/sdk package.
How this skill is triggered — by the user, by Claude, or both
Slash command
/linear:linearThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
```!
# === Linear Skill Environment Check ===
BLOCKED=""
# Detect package manager
if [ -f "yarn.lock" ]; then
PKG_MGR="yarn" PKG_ADD="yarn add" PKG_GLOBAL="yarn global add"
elif [ -f "pnpm-lock.yaml" ]; then
PKG_MGR="pnpm" PKG_ADD="pnpm add" PKG_GLOBAL="pnpm add -g"
else
PKG_MGR="npm" PKG_ADD="npm install" PKG_GLOBAL="npm install -g"
fi
# 1. API Key check
if [ -z "$LINEAR_API_KEY" ]; then
BLOCKED="yes"
echo "❌ BLOCKED: LINEAR_API_KEY not set"
echo ""
echo "STOP. Do not attempt Linear operations."
echo "Ask user to set their API key:"
echo " export LINEAR_API_KEY=lin_api_..."
echo ""
echo "Get key from: Linear → Settings → API → Personal API keys"
else
case "$LINEAR_API_KEY" in
lin_api_*)
echo "✓ Linear API key set (${LINEAR_API_KEY:0:12}...)"
;;
*)
# Non-standard format - warn but don't block (might still work)
echo "✓ LINEAR_API_KEY set (${LINEAR_API_KEY:0:8}...)"
echo " ⚠️ Unexpected format (should start with lin_api_)"
;;
esac
fi
# 2. Runtime check
if command -v tsx >/dev/null 2>&1; then
echo "✓ tsx $(tsx --version 2>&1 | head -1)"
else
BLOCKED="yes"
echo "❌ BLOCKED: tsx not installed"
echo " Cannot execute scripts. Install with: $PKG_GLOBAL tsx"
fi
# 3. Package check
if [ -d "node_modules/@linear/sdk" ]; then
VER=$(node -p "JSON.parse(require('fs').readFileSync('node_modules/@linear/sdk/package.json')).version" 2>/dev/null || echo "?")
echo "✓ @linear/sdk@$VER"
elif command -v $PKG_MGR >/dev/null 2>&1 && $PKG_MGR list @linear/sdk 2>/dev/null | grep -q @linear/sdk; then
echo "✓ @linear/sdk ($PKG_MGR)"
else
BLOCKED="yes"
echo "❌ BLOCKED: @linear/sdk not installed"
echo " Cannot execute scripts. Install with: $PKG_ADD @linear/sdk"
fi
# Final status (must exit 0 to not fail skill load)
if [ -z "$BLOCKED" ]; then
echo ""
echo "Ready to execute Linear operations."
fi
Uses @linear/sdk with tsx. Run inline scripts using heredocs for top-level await support:
tsx << 'EOF'
import { LinearClient } from "@linear/sdk";
const client = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });
// your code with top-level await
EOF
IMPORTANT: Use tsx << 'EOF' ... EOF heredoc syntax for inline execution with top-level await. The tsx -e flag does NOT support top-level await.
1. Is this from my bot?
→ if (webhook.data.user?.id === viewerId) return; // STOP - prevent loop
2. What type?
→ Issue: webhooks/issue.md
→ Comment: webhooks/comment.md
→ Project: webhooks/project.md
→ Cycle: webhooks/cycle.md
1. Check if mentioned: /@claude\b/i.test(webhook.data.body)
2. Get issue context: await client.issue(webhook.data.issue.identifier)
3. Reply: await client.createComment({ issueId: issue.id, body: "..." })
1. Detect: webhook.updatedFrom?.assigneeId exists
2. Is it me? webhook.data.assignee?.id === viewerId
3. Acknowledge: update state + add comment (see sdk/issues.md#updating)
1. Detect: webhook.updatedFrom?.stateId exists
2. Get transition: webhook.updatedFrom.state.type → webhook.data.state.type
3. React based on new type (started, completed, etc.)
IMPORTANT: All scripts use inline heredoc execution - no script files are created. No cleanup typically required.
Best Practice: Process API responses in memory, output results to console rather than files.
| Issue | Reality | Fix |
|---|---|---|
| Bot loops | Your comments trigger webhooks | Check webhook.data.user.id === viewerId first |
| Unassigned check | SDK returns undefined, not null | Use !assignee or === undefined |
| State types | Multiple states can share same type | "canceled" may have "Canceled" AND "Duplicate" |
SDK name field | viewer.name = email, not display name | Use viewer.displayName for display |
issue.parent | Returns undefined for top-level | Webhooks send null, SDK returns undefined |
| State IDs | Team-specific | Re-lookup by state.type when moving issues between teams |
type | File |
|---|---|
Issue | webhooks/issue.md |
Comment | webhooks/comment.md |
Project, ProjectUpdate | webhooks/project.md |
Cycle | webhooks/cycle.md |
| I want to... | File |
|---|---|
| Reply to comment/mention | sdk/comments.md#creating |
| Change issue status | sdk/issues.md#updating |
| Get issue details | sdk/issues.md#getting |
| Find workflow states | sdk/queries.md#teams |
| Find user IDs | sdk/queries.md#users |
| Pattern | Meaning |
|---|---|
updatedFrom.assigneeId exists | Assignment changed |
updatedFrom.stateId exists | Status changed |
updatedFrom.labelIds exists | Labels changed |
action: "create" + type: "Comment" | New comment |
priority: 1=Urgent, 2=High, 3=Normal, 4=Low, 0=None
state.type: backlog → unstarted → started → completed | canceled
# Get viewer ID for loop prevention
tsx << 'EOF'
import { LinearClient } from "@linear/sdk";
const client = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });
const viewer = await client.viewer;
const viewerId = viewer.id; // Cache at startup
console.log("Viewer ID:", viewerId);
console.log("Display Name:", viewer.displayName); // NOT viewer.name!
// In webhook handler - check FIRST:
// if (webhook.type === "Comment" && webhook.data.user?.id === viewerId) return;
EOF
# Check if @claude is mentioned in webhook body
WEBHOOK_BODY="Hello @claude please help" tsx << 'EOF'
const body = process.env.WEBHOOK_BODY || "";
const wasMentioned = /@claude\b/i.test(body);
console.log("Was mentioned:", wasMentioned);
EOF
# Extract issue identifiers like ENG-123, PROJ-456
TEXT="See ENG-123 and PROJ-456" tsx << 'EOF'
const body = process.env.TEXT || "";
const refs = [...new Set(body.match(/[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d+/g) || [])];
console.log("Issue refs:", refs);
EOF
# Find workflow state by type
ISSUE_ID="ENG-123" tsx << 'EOF'
import { LinearClient } from "@linear/sdk";
const client = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });
const issue = await client.issue(process.env.ISSUE_ID!);
const team = await issue.team;
const states = await team?.states();
const inProgress = states?.nodes.find(s => s.type === "started");
console.log("In Progress state:", inProgress?.name, inProgress?.id);
// ⚠️ Multiple states may match - this returns first
EOF
// In webhook handler - detect completion
const wasCompleted =
webhook.updatedFrom?.state?.type !== "completed" &&
webhook.data.state?.type === "completed";
ISSUE_ID="ENG-1234" tsx << 'EOF'
import { LinearClient } from "@linear/sdk";
const client = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });
const issue = await client.issue(process.env.ISSUE_ID!);
const state = await issue.state;
console.log("Issue:", issue.identifier, issue.title);
console.log("State:", state?.name, state?.type);
EOF
ISSUE_ID="ENG-1234" STATE_ID="state-uuid" tsx << 'EOF'
import { LinearClient } from "@linear/sdk";
const client = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });
await client.updateIssue(process.env.ISSUE_ID!, { stateId: process.env.STATE_ID });
console.log("Issue updated");
EOF
ISSUE_ID="ENG-1234" tsx << 'EOF'
import { LinearClient } from "@linear/sdk";
const client = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });
const issue = await client.issue(process.env.ISSUE_ID!);
await client.createComment({ issueId: issue.id, body: "Done." });
console.log("Comment added");
EOF
tsx << 'EOF'
import { LinearClient } from "@linear/sdk";
const client = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });
const viewer = await client.viewer;
console.log("ID:", viewer.id);
console.log("Display Name:", viewer.displayName); // NOT viewer.name!
EOF
npx claudepluginhub goodfoot-io/marketplace --plugin linearCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.