DEV Community

Jude Agboola
Jude Agboola

Posted on

Building a Sandboxed Linear Agent Powered by Claude Code and Sprite.dev

By the end of this guide you'll have a Linear agent you can assign an issue to. It spins up its own sandbox, runs Claude Code to do the work, and streams the agent's thoughts, tool calls, and results back into the issue in real time.

Linear Agents are a built-in Linear feature that lets you assign issues directly to AI agents so they can take action, do the work, and drive tasks toward completion on their own. The thing Linear got right is co-locating the agent and the task: the work, the context, and the agent all live in the same place.

You can assign coding tasks to agents like Cursor, Codex, or the Linear coding agent, route research to something like CelCog, or roll your own specialised agent. This Article is about the last option.

At Membrane we built a Powerful Linear agent that handles core coding tasks against our public connectors library: it makes the change, test the connectors on our platform to confirms the change is correct, and hands the rest — verification and publishing — off to a human.

Such a specialised agent can be a powerful tool for teams, especially when it’s embedded directly into task management tools like Linear, where it fits naturally into existing workflows and turns work into something that can be executed and tracked end-to-end.

In this article, We'll build on Simple Linear Agent using 2 tools tools:

  • Claude Code — Anthropic's coding agent. It's the right fit here because the agent needs to run shell commands, edit files, and use real tools.
  • Sprite.dev — stateful sandbox environments where the agent can run code and persist files. Instead of cloning repos onto our own server and running untrusted work there (with all the downsides that brings), we spin up an isolated environment per task. Sprite also ships the major CLI tools pre-installed — claude, codex, gh, git, and more — so the agent can use them without any setup on our end.

Three steps:

  1. Set up a Linear OAuth app.
  2. Add the OAuth app to your Linear workspace as an agent.
  3. Handle webhooks: spin up a sandbox and run Claude Code.

The complete, runnable source for everything below lives on GitHub: marvinjude/linear-agent-example-with-sprite. Clone it to follow along, or read on for how the pieces fit together.

Set up a Linear OAuth app

Create an OAuth app from your workspace's API settings page:

https://linear.app/{workspace-key}/settings/api
Enter fullscreen mode Exit fullscreen mode

There you set the app name, redirect URI, whether other workspaces can install the app, the events to listen for, and whether to enable client credentials.

Client credentials are the simpler path when you're only building the agent for your own workspace. If you want the agent installable in other workspaces, use the standard OAuth flow instead: users authorize with Linear, Linear redirects to your redirect URI, you exchange the code for tokens, and you handle token refresh. Linear documents that flow in the agent authentication guide.

Scopes

Most agents should request these scopes:

  • app:assignable — issues can be assigned to the agent
  • app:mentionable — the agent can be @-mentioned
  • read — read data from Linear
  • write — write data to Linear

See the full list of scopes.

Add the OAuth app to your workspace as an agent

You can add the app via the standard OAuth flow or with client credentials. This example uses client credentials. Once we request for access token using the client crendetials the OAuth app gets added as an agent to the workspace.

import { LinearClient } from "@linear/sdk";
import { config } from "../config.js";

let cachedToken: string | null = null;
let tokenExpiresAt = 0;

export async function getLinearAccessToken(): Promise<string> {
  if (cachedToken && Date.now() < tokenExpiresAt) return cachedToken;

  const body = new URLSearchParams({
    client_id: config.linear.oauthClientId,
    client_secret: config.linear.oauthClientSecret,
    grant_type: "client_credentials",
    scope: "read,write,app:mentionable,app:assignable",
  });

  const res = await fetch("https://api.linear.app/oauth/token", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: body.toString(),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Linear OAuth token request failed (${res.status}): ${text}`);
  }

  const data = (await res.json()) as { access_token: string; expires_in: number };
  cachedToken = data.access_token;
  tokenExpiresAt = Date.now() + (data.expires_in - 60) * 1000;
  return cachedToken;
}

export async function createLinearClient(): Promise<LinearClient> {
  const accessToken = await getLinearAccessToken();
  return new LinearClient({ accessToken });
}
Enter fullscreen mode Exit fullscreen mode

We authenticate with the client credentials, cache the access token and reuse it for every operation against Linear.

Authenticating Claude Code in the sandbox

Claude Code needs an OAuth token to talk to Anthropic's API. In a normal interactive setup you'd run claude and it walks you through a browser login — but inside a Sprite you want to pre-authenticate it.

Run this once on your local machine to generate a long-lived token:

claude setup-token
Enter fullscreen mode Exit fullscreen mode

This command prints an OAuth token. To authenticate Claude Code, store the token in the CLAUDE_CODE_OAUTH_TOKEN secret and inject it into every sandbox when it is spawned. Make sure to have a Claude Code subscription.

const cmd = sprites.sprite(spriteName).spawn("claude", args, {
  env: { CLAUDE_CODE_OAUTH_TOKEN: config.claude.oauthToken },
});
Enter fullscreen mode Exit fullscreen mode

Claude Code reads that env var on startup and skips the interactive login entirely.

Handling webhooks: spin up a sandbox and run Claude Code

We need two webhook handlers:

  • Session created — fires when an issue is assigned to the agent. Creates a sandbox and kicks off the first Claude Code run.
  • New message — fires when someone adds a message to the session. Resumes the existing session with the new prompt.

The two correspond to two ways of invoking Claude:

# Session created: start a new Claude Code session
claude \
  --session-id $LINEAR_AGENT_SESSION_ID \
  --output-format stream-json \
  -p "Linear issue ISSUE-123 is now assigned to you" \
  --system-prompt "You are a deployment verification agent..."
Enter fullscreen mode Exit fullscreen mode
# New message: resume the existing session with a new prompt
claude --resume $LINEAR_AGENT_SESSION_ID \
  --output-format stream-json \
  -p "Can you also check the integration tests?"
Enter fullscreen mode Exit fullscreen mode

--output-format stream-json makes Claude emit structured JSON events on stdout instead of plain text. We parse each event as it arrives and forward it to Linear in real time — posting thoughts, tool calls, and responses as the session runs.

Agent session creation handler

Here's a concrete handler for the "session created" webhook. It starts a new sandbox, boots Claude Code inside it, and forwards Claude's structured events into Linear as agent session activities.

Agent session activities represent the running agent's lifecycle:

  • thought — internal reasoning or planning (not shown to end users as final output).
  • text — user-facing responses or summaries.
  • tool_call / tool_result — the agent invoking a tool (running tests, making a network call) and the tool's output.
  • status updates — session state: started, resumed, finished, errored.

The handler parses newline-delimited JSON from Claude Code and translates each event block into the matching Linear activity, so the Linear session shows the agent's thoughts, actions, and results as they happen.

export interface AgentSessionEvent {
  action: string;
  type: string;
  agentSession: {
    id: string;
    issue?: { id: string; identifier: string };
  };
  agentActivity?: {
    content?: { body?: string };
  };
  promptContext?: string;
}

async function handleSessionCreated(event: AgentSessionEvent): Promise<void> {
  const sessionId = event.agentSession.id;

  const name = spriteName(sessionId);

  const linear = await createLinearClient();

  // Create a fresh sandbox and write the Claude config into it
  await sprites.createSprite(name);

  // Write ~/claude.json file to with MCP server config
  await addClaudeConfigToSprite(name);

  // .... Run other arbitray code needed to get the Sprite (Sandbox) setup

  // Spawn Claude inside the sandbox, streaming newline-delimited JSON on stdout
  const cmd = sprites.sprite(name).spawn(
    "claude",
    [
      "--output-format", "stream-json",
      "--verbose",
      "--dangerously-skip-permissions",
      "--session-id", sessionId,
      "-p",  event.promptContext,
      "--system-prompt", "You are a coding agent...",
    ],
    // 
    { env: { CLAUDE_CODE_OAUTH_TOKEN: config.claude.oauthToken } },
  );

  cmd.stdout.on("data", (chunk: Buffer) => {
    for (const line of chunk.toString().split("\n")) {
      if (!line.trim()) continue;
      const event = JSON.parse(line);
      if (event.type !== "assistant") continue;

      for (const block of event.message.content) {
        if (block.type === "thinking") {
          // Internal reasoning — rendered as a thought bubble in Linear
          linear.createAgentActivity({
            agentSessionId: sessionId,
            content: { type: "thought", body: block.thinking },
          });
        } else if (block.type === "text") {
          // Final response — rendered as a message in Linear
          linear.createAgentActivity({
            agentSessionId: sessionId,
            content: { type: "response", body: block.text },
          });
        } else if (block.type === "tool_use") {
          // Tool call in progress — rendered as an ephemeral status update
          linear.createAgentActivity({
            agentSessionId: sessionId,
            ephemeral: true,
            content: {
              type: "action",
              action: block.name,
              parameter: JSON.stringify(block.input),
            },
          });
        }
      }
    }
  });

  // See: https://linear.app/developers/agents#agent-activities for the full
  // list of activity types and their fields.
  await new Promise<void>((resolve, reject) => {
    cmd.on("exit", resolve);
    cmd.on("error", reject);
  });
}
Enter fullscreen mode Exit fullscreen mode

Adding integrations to Claude Code

Most real tasks reach beyond the repo: checking code into GitHub, posting to Slack, updating a record in Attio, or updating the Linear issue itself. The agent needs access to those integrations, which you can give it two ways — MCP servers or CLIs.

Via MCP servers

If an app ships an MCP server — or you use an integration platform that exposes many integrations over MCP, like Membrane — add the server config to ~/.claude.json in the sandbox so Claude Code can use it:

export async function addClaudeConfigToSprite(spriteName: string): Promise<void> {
  const linearApiToken = await getLinearAccessToken();

  const claudeConfig = {
    mcp_servers: {
      linear: {
        type: "http",
        url: "https://mcp.linear.app/mcp",
        headers: {
          Authorization: `Bearer ${linearApiToken}`,
        },
      },
      // add other MCP server configs here
    },
  };

  const cmd = sprites
    .sprite(spriteName)
    .spawn("sh", ["-c", `echo '${JSON.stringify(claudeConfig)}' > ~/.claude.json`]);
}
Enter fullscreen mode Exit fullscreen mode

Via CLIs

There's been growing momentum toward CLIs over MCP for one reason: MCP servers expose many tools, and their metadata gets sent to the model on every request, eating into context. CLIs sidestep that — you hand the agent focused instructions for one command and keep the context lean.

Sprite ships the major CLI tools already installed — claude, codex, gh, git, and more — so for those there's nothing to do. For anything Sprite doesn't ship (a vendor-specific CLI, say), spawn the install command inside the sandbox:

async function installCLITools(spriteName: string): Promise<void> {
  // Linear CLI (linearis — not bundled with Sprite, so install it ourselves)
  await sprites.sprite(spriteName).spawn("bun", ["install", "-g", "linearis"]);
}
Enter fullscreen mode Exit fullscreen mode

Most integration CLIs authenticate through environment variables — linearis, for example, reads LINEAR_API_TOKEN.

Inject the tokens when you spawn Claude to authenticate the CLI:

const env = {
  GH_TOKEN: config.github.token, // GitHub fine-grained token
  LINEAR_API_TOKEN: linearAccessToken, // access token from client credentials
};

const cmd = sprites.sprite(spriteName).spawn("claude", args, { env });
Enter fullscreen mode Exit fullscreen mode

Going further: agent signals

Beyond posting activities, Linear lets you attach a signal to an activity — optional metadata that tells the recipient how to interpret it. Signals open up a richer back-and-forth between the agent and the user:

  • stop — the user asks the agent to halt mid-run; you handle it by killing the current job and posting a final status.
  • select — the agent presents structured choices instead of free-text questions, and the user's pick comes back as a new prompt.
  • auth — the agent asks the user to link a third-party account so it can act on their behalf in another system.

These are the natural next step for making the agent interactive. See the agent signals docs for the full list and payload shapes.

Summary

You now have the full loop: an OAuth app registered as a Linear agent, a webhook handler that spins up an isolated Sprite sandbox per task, Claude Code running inside it with streamed events posted back to the issue, and integrations wired in over MCP or CLIs. From here, the highest-leverage additions are agent signals for interactivity and a tighter system prompt encoding your team's specific process — the part that turns a generic coding agent into one that does your work.

The full project is on GitHub at marvinjude/linear-agent-example-with-sprite — clone it, drop in your own credentials, and assign it an issue.

Top comments (0)