Joe Bruechner
All projects

Claude Code Linear Agent

Jan 2026 –

A self-hosted agent that brings Claude Code's reasoning directly into Linear issue workflows.

Interested? Reach out — I'd love to provide the source code or answer any questions.

TypeScriptNode.jsLinearClaudefile_type_nginxNginx
~$11/mo
Total cost
<5s
Webhook ack
3
Concurrent sessions

Why I Built This

Linear already has a Claude.ai chatbot integration, but the API itself was just not what I was looking for. I wanted the full standard of Claude Code examining my codebase: reading files, understanding context, running tests, and proposing changes based on actual code analysis — not just conversation.

There is a Linear integration for Claude Code, but it only works one direction. You can trigger it from Claude desktop or web to update Linear, but you can't @mention it from Linear to start a Claude Code session. That's backwards for how I actually work.

Existing bidirectional solutions cost $50 to $200 per month for what amounts to a webhook wrapper. I already had a Claude subscription for Claude Code access. I already had the technical infrastructure. The problem was architectural: Claude Code runs as a CLI tool, not an API service. There's no native webhook support for Linear to trigger it, no documented pattern for programmatic access.

So I built the infrastructure myself. A VPS running a webhook server that receives Linear @mentions, spawns isolated Claude Code sessions with full issue context, manages execution and timeouts, and streams results back through Linear's Agent Activity API. The entire integration layer that would take months for a company to ship properly, done in a day, running in production for $11 per month.

This isn't a demo or a side project that collects dust. I use it daily on Atticus AI. When I'm not at my computer and I know there was an issue, I just drop a comment from my phone for Claude Code to take a look, and I have a synopsis or even a PR in 5 to 10 minutes.

Architecture

The system is an Express server that receives webhooks from Linear. When a webhook arrives, the server acknowledges instantly (Linear enforces a 5-second response window) and queues the actual work asynchronously. An initial "thinking" activity is posted to Linear within seconds so the user sees the agent is alive.

The prompt builder fetches the full issue context — title, description, labels, priority, and recent comments — then classifies the task. Simple fixes get a full git workflow with branch naming conventions and PR templates. Investigations stay read-only. Complex analysis tasks provide architectural guidance without attempting implementation.

Claude Code is spawned as a direct child process with no shell intermediary. Arguments go through execvp, which eliminates shell injection as an attack vector entirely. Only seven environment variables are allowlisted for the child process — the OAuth token, webhook secret, and all other server-side secrets never reach Claude.

  • Smart task routing — classifies issues and adjusts behavior accordingly
  • Follow-up conversations — context carries forward between exchanges
  • Multi-repo support — routes issues to repos based on Linear project name
  • Automatic PR creation with branch naming and URL detection
  • Concurrent session limiting (default 3) to prevent resource exhaustion
  • HMAC-SHA256 webhook signature validation on every request

What Made It Hard

The stdin trap was the first real blocker. Claude Code CLI hangs indefinitely if you don't close stdin on the spawned process — it sits there waiting for input that will never come. The fix is a single line (claude.stdin.end()) but it took real debugging time to figure out why a successfully spawned process would produce zero output and eventually time out.

Linear's OAuth for the Agent Activity API is unusual. It requires a client_credentials grant with actor=application, not the standard authorization code flow. Personal API keys don't work — you get cryptic access-denied errors. The Linear SDK doesn't expose the agent activity mutations directly either, so the agent uses raw GraphQL fetch calls. And the authorization header expects the bare token without a Bearer prefix.

Timeout tuning was another surprise. The default 3-minute execution limit seemed generous until Claude (especially Opus) started analyzing real codebases. Even moderate investigation tasks regularly take 5-8 minutes. The recommended setting is 10 minutes. And the Linear CLI's credential storage (keytar) requires D-Bus and a desktop keyring — neither of which exist on a headless VPS. That was a complete blocker until the Linear CLI added environment variable authentication support.

An @mention in Linear triggers a full Claude Code session — analysis posted back in minutes.
The agent investigates the issue, creates a branch, implements the fix, and opens a PR — all from an @mention.
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import type { LinearClient } from './linear-client.js';
import type { ExecutionResult } from './types.js';

const PR_URL_REGEX = /https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/g;

// Only these env vars are passed to the Claude child process.
// This prevents leakage of LINEAR_OAUTH_ACCESS_TOKEN,
// LINEAR_CLIENT_SECRET, LINEAR_WEBHOOK_SECRET, etc.
const ALLOWED_CHILD_ENV_VARS = [
  'GITHUB_TOKEN',
  'LINEAR_API_KEY',
  'HOME',
  'PATH',
  'NODE_ENV',
  'LANG',
  'TERM',
];

export class ClaudeExecutor {
  private claudePath: string;
  private defaultWorkspaceDir: string;
  private maxExecutionTime: number;

  constructor() {
    this.claudePath = process.env.CLAUDE_PATH
      || '/home/claude-agent/.local/bin/claude';
    this.defaultWorkspaceDir = process.env.WORKSPACE_DIR
      || '/home/claude-agent/repos/default';
    this.maxExecutionTime = parseInt(
      process.env.MAX_EXECUTION_TIME || '600000', 10
    );
  }

  private buildChildEnv(): Record<string, string> {
    const env: Record<string, string> = {};
    for (const key of ALLOWED_CHILD_ENV_VARS) {
      if (process.env[key]) env[key] = process.env[key]!;
    }
    env.HOME = process.env.HOME || '/home/claude-agent';
    env.PATH = [
      '/home/claude-agent/.local/bin',
      '/home/claude-agent/.npm-global/bin',
      '/usr/local/bin',
      '/usr/bin',
      '/bin',
    ].join(':');
    return env;
  }

  async executeAndReport(
    prompt: string,
    sessionId: string,
    issueIdentifier: string,
    linearClient: LinearClient,
    workspaceDir?: string
  ): Promise<ExecutionResult> {
    const workspace = workspaceDir || this.defaultWorkspaceDir;

    return new Promise((resolve, reject) => {
      let stdout = '';
      let stderr = '';

      // Timeout — kill the process if it runs too long
      const timeoutId = setTimeout(() => {
        claude.kill('SIGTERM');
        linearClient.sendError(sessionId,
          'Session timed out. The task may be too complex.'
        );
        reject(new Error('Execution timeout'));
      }, this.maxExecutionTime);

      // Spawn Claude directly — no shell, no escaping needed.
      // Arguments pass through execvp, eliminating shell injection.
      const claude = spawn(this.claudePath, [
        '-p', prompt,
        '--dangerously-skip-permissions',
        '--output-format', 'json',
      ], {
        cwd: workspace,
        env: this.buildChildEnv(),
        stdio: ['pipe', 'pipe', 'pipe'],
      });

      // CRITICAL: Close stdin immediately. Claude Code hangs
      // indefinitely if stdin stays open — it waits for input
      // that will never come.
      claude.stdin.end();

      claude.stdout.on('data', (data: Buffer) => {
        stdout += data.toString();
      });
      claude.stderr.on('data', (data: Buffer) => {
        stderr += data.toString();
      });

      claude.on('close', async (code) => {
        clearTimeout(timeoutId);

        if (!stdout) {
          const errorMsg = stderr || `Exited with code ${code}`;
          await linearClient.sendError(sessionId, errorMsg);
          return reject(new Error(errorMsg));
        }

        const result = JSON.parse(stdout);
        const responseText =
          result.result || result.response || result.content;
        await linearClient.sendResponse(sessionId, responseText);

        // Detect PR URLs in the response
        const prUrls = responseText.match(PR_URL_REGEX);
        resolve({ responseText, prUrl: prUrls?.[0] });
      });
    });
  }
}
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import type { LinearClient } from './linear-client.js';
import type { ExecutionResult } from './types.js';

const PR_URL_REGEX = /https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/g;

// Only these env vars are passed to the Claude child process.
// This prevents leakage of LINEAR_OAUTH_ACCESS_TOKEN,
// LINEAR_CLIENT_SECRET, LINEAR_WEBHOOK_SECRET, etc.
const ALLOWED_CHILD_ENV_VARS = [
  'GITHUB_TOKEN',
  'LINEAR_API_KEY',
  'HOME',
  'PATH',
  'NODE_ENV',
  'LANG',
  'TERM',
];

export class ClaudeExecutor {
  private claudePath: string;
  private defaultWorkspaceDir: string;
  private maxExecutionTime: number;

  constructor() {
    this.claudePath = process.env.CLAUDE_PATH
      || '/home/claude-agent/.local/bin/claude';
    this.defaultWorkspaceDir = process.env.WORKSPACE_DIR
      || '/home/claude-agent/repos/default';
    this.maxExecutionTime = parseInt(
      process.env.MAX_EXECUTION_TIME || '600000', 10
    );
  }

  private buildChildEnv(): Record<string, string> {
    const env: Record<string, string> = {};
    for (const key of ALLOWED_CHILD_ENV_VARS) {
      if (process.env[key]) env[key] = process.env[key]!;
    }
    env.HOME = process.env.HOME || '/home/claude-agent';
    env.PATH = [
      '/home/claude-agent/.local/bin',
      '/home/claude-agent/.npm-global/bin',
      '/usr/local/bin',
      '/usr/bin',
      '/bin',
    ].join(':');
    return env;
  }

  async executeAndReport(
    prompt: string,
    sessionId: string,
    issueIdentifier: string,
    linearClient: LinearClient,
    workspaceDir?: string
  ): Promise<ExecutionResult> {
    const workspace = workspaceDir || this.defaultWorkspaceDir;

    return new Promise((resolve, reject) => {
      let stdout = '';
      let stderr = '';

      // Timeout — kill the process if it runs too long
      const timeoutId = setTimeout(() => {
        claude.kill('SIGTERM');
        linearClient.sendError(sessionId,
          'Session timed out. The task may be too complex.'
        );
        reject(new Error('Execution timeout'));
      }, this.maxExecutionTime);

      // Spawn Claude directly — no shell, no escaping needed.
      // Arguments pass through execvp, eliminating shell injection.
      const claude = spawn(this.claudePath, [
        '-p', prompt,
        '--dangerously-skip-permissions',
        '--output-format', 'json',
      ], {
        cwd: workspace,
        env: this.buildChildEnv(),
        stdio: ['pipe', 'pipe', 'pipe'],
      });

      // CRITICAL: Close stdin immediately. Claude Code hangs
      // indefinitely if stdin stays open — it waits for input
      // that will never come.
      claude.stdin.end();

      claude.stdout.on('data', (data: Buffer) => {
        stdout += data.toString();
      });
      claude.stderr.on('data', (data: Buffer) => {
        stderr += data.toString();
      });

      claude.on('close', async (code) => {
        clearTimeout(timeoutId);

        if (!stdout) {
          const errorMsg = stderr || `Exited with code ${code}`;
          await linearClient.sendError(sessionId, errorMsg);
          return reject(new Error(errorMsg));
        }

        const result = JSON.parse(stdout);
        const responseText =
          result.result || result.response || result.content;
        await linearClient.sendResponse(sessionId, responseText);

        // Detect PR URLs in the response
        const prUrls = responseText.match(PR_URL_REGEX);
        resolve({ responseText, prUrl: prUrls?.[0] });
      });
    });
  }
}
Spawns Claude Code with env sandboxing. The stdin.end() call on line 83 was the hardest bug to find.