all articles

AI-Native Delivery: Claude Code Agents, Linear and GitHub Actions

Martin Oputa·October 10, 2026
Claude CodeAI AgentsGitHub ActionsLinearCI/CD

On PracticeDepth, most small changes start as a Linear ticket and end as a draft pull request that I review, without me opening an editor in between. I write the ticket, add a claude label, and close the laptop. A Claude Code agent picks it up on a GitHub Actions runner, writes the change and its tests, runs the checks, and opens a draft PR. I read it, merge it, and the normal pipeline deploys it.

This post is the short version of how to set that up. It is not complicated, but there are a few places where the obvious setup quietly fails, and one rule I would not run without: some code is human-only. On my project, that is everything that moves money.

The Flow

  1. A ticket is written in Linear. Linear's GitHub sync mirrors it to a GitHub issue, both ways.
  2. Adding the claude label (in Linear or GitHub) syncs to the issue and starts a workflow.
  3. A fresh runner checks out the repo, starts a throwaway database, and runs Claude Code against the ticket.
  4. The agent writes code and tests, runs typecheck, lint and tests, pushes a claude/issue-N branch, and opens a draft PR.
  5. CI runs on the PR: the normal test gate, plus a guard that fails if the agent touched protected code.
  6. A human reviews, asks for changes with @claude, or merges. Merging deploys, and the Linear ticket moves to Done.

Label five tickets and you get five runners, five agents, five branches, all in parallel. That is all “I have five agents running” means here.

Step 1: Connect Claude to the Repo

Install the Claude GitHub App, give the workflow a token, and create the label. The token from claude setup-token bills against your Claude subscription. If you would rather pay per token, use an ANTHROPIC_API_KEY secret instead.

bash
# 1. Install the Claude GitHub App on your repo (run inside Claude Code)
/install-github-app

# 2. Create a token from your Claude subscription and store it as a repo secret
claude setup-token
gh secret set CLAUDE_CODE_OAUTH_TOKEN

# 3. Create the label that dispatches an agent
gh label create claude --color 7C3AED --description "Hand this ticket to an agent"

One trap: the app installer offers a starter PR with its own generic @claude workflow. Close it if you are writing your own, or every mention will start two agents.

Also turn on Settings → Actions → General → Allow GitHub Actions to create and approve pull requests, which the fallback step below needs.

Step 2: Sync Linear and GitHub Both Ways

In Linear, connect the GitHub integration, then enable GitHub issue sync between your repo and a team, set to two-way. Connect your personal GitHub account too, so synced actions show up as you rather than a bot. Create a claude label in Linear with the exact same name; labels sync by name.

Then set two Linear workflow automations: a PR opened (including drafts) moves the ticket to In Review, and a merged PR moves it to Done. Now the board updates itself as agents work.

Step 3: Decide What Agents May Never Touch

Before writing the workflow, write the list. Mine is one file, and it drives two of the three protections below. Paths cover whole files, model: lines cover database tables, and content: patterns catch protected code that is mixed into otherwise ordinary files.

text
# .github/agent-protected.txt
# Payment code is human-only. One file feeds two hard layers:
#   1. the agent workflow turns every path into an Edit/Write deny rule
#   2. the guard fails any agent PR whose diff touches a path, model or pattern
#
# Format: a plain line is a path glob (* = one folder, ** = any depth)
#         model: X       protects a model block in prisma/schema.prisma
#         content: <re>  flags any added or removed line that matches

src/lib/stripe.ts
src/app/api/stripe/**
src/lib/billing/**

# The guard itself and CI
.github/**
scripts/agent-guard.mjs

model: Purchase
model: CreditLedger

content: STRIPE_
content: from ["']stripe["']

Note that .github/** is on the list. An agent that can edit the workflow can edit its own rules.

Step 4: The Agent Workflow

This is the core of it. It only starts on the label or an explicit @claude mention, gives each ticket its own queue, turns the protected list into a deny list for the agent's Edit and Write tools, and only allows the shell commands the agent needs to check its own work.

yaml
# .github/workflows/claude-agent.yml
name: Claude agent

on:
  issues:
    types: [labeled]
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]

jobs:
  agent:
    # Only the label or an explicit mention. Without this, every comment
    # on every issue boots a runner just to decide there is nothing to do.
    if: |
      (github.event_name == 'issues' && github.event.label.name == 'claude') ||
      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude'))
    runs-on: ubuntu-latest
    timeout-minutes: 45
    # One agent per ticket. Different tickets still run in parallel.
    concurrency:
      group: claude-${{ github.event.issue.number || github.event.pull_request.number }}
      cancel-in-progress: false
    permissions:
      contents: write
      pull-requests: write
      issues: write
      id-token: write

    # A throwaway database, so the agent can run integration tests.
    # No production secret ever enters this job.
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: app
        ports: ["5432:5432"]

    env:
      DATABASE_URL: postgresql://app:app@localhost:5432/app
      BACKGROUND_AGENT: "1"   # CLAUDE.md switches to its "nobody is watching" rules

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci

      # Every path in agent-protected.txt becomes Edit(path),Write(path)
      - name: Build the agent's deny list
        id: protected
        run: |
          tools=$(grep -vE '^[[:space:]]*(#|$|model:|content:)' .github/agent-protected.txt \
            | sed -E 's/.*/Edit(&),Write(&)/' | paste -sd, -)
          echo "tools=$tools" >> "$GITHUB_OUTPUT"

      - uses: anthropics/claude-code-action@v1
        id: claude
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          label_trigger: claude
          trigger_phrase: "@claude"
          branch_prefix: claude/
          track_progress: true
          # Linear's sync may add the label as its bot, not as you.
          allowed_bots: "linear,linear[bot]"
          claude_args: |
            --max-turns 80
            --disallowedTools "${{ steps.protected.outputs.tools }}"
            --allowedTools "Bash(npm run typecheck),Bash(npm run lint:*),Bash(npm test:*),Bash(git diff:*),Bash(git status),Bash(gh pr create:*)"

A few decisions worth copying:

  • No production secrets. The only secret in the job is the Claude token. The database is a container that dies with the job.
  • Agents only push claude/* branches. They cannot push to main and cannot merge.
  • The queue rule sits on the job, not the workflow. With Linear syncing, one ticket fires an assigned event, a labeled event and a Linear comment within a second. With the concurrency rule at workflow level, Linear's comment took the only waiting slot in GitHub's queue and the real run got cancelled. On the job, the if is decided first, so only runs that really start the agent queue up.

Step 5: Tell the Agent How to Behave

Claude Code reads CLAUDE.md from the repo. Add a section that only applies when nobody is watching. The workflow sets an environment variable so the agent knows which rules are in force.

markdown
## Running as a background agent

When `BACKGROUND_AGENT=1` is set, you were started from a ticket and
nobody is watching. The ticket is the approval.

- **Build, don't ask.** Take the most reasonable reading of the ticket and
  say which one you took. If it is too ambiguous to finish safely, stop and
  post a plan as a comment instead of code.
- **Stay inside the ticket.** No drive-by refactors, no dependency upgrades.
  A small diff is a reviewable diff.
- **Bugs: failing test first.** Reproduce it, watch it fail, then fix it.
- **Before you finish, run** `npm run typecheck`, `npm run lint`, `npm test`.
- **Payment code is human-only.** Everything in .github/agent-protected.txt
  is off limits. If the ticket needs it, stop and comment what would need
  to change, so a human can do that part by hand.
- **You cannot verify in a browser.** Say so, and list what to click through.
- **Open a draft PR as your last step:**
  `gh pr create --draft --base main --title "[ENG-12] ..." --body "Fixes ENG-12"`

The ticket quality is the PR quality. A good agent ticket has one concern, a checkable outcome and an explicit “out of scope”. “The scorecard shows NaN when a session has zero graded turns, show a dash instead” works. “Improve the interview quality” does not. For those, I ask @claude propose a plan on the issue without the label, then turn the plan into small tickets.

Step 6: Always End With a Draft PR

The agent is told to open the draft PR itself, and most of the time it does. Early on, it skipped it on most runs. So a script step runs after the agent either way and opens the PR if it is missing. It also puts the Linear ID in the title, because Linear links PRs by its own ID ([ENG-12]), not by GitHub's Closes #40.

yaml
      # Runs after Claude either way. If Claude opened the PR, this does nothing.
      # If it didn't, the script opens it. What must always happen is never
      # left to the agent alone.
      - name: Make sure a draft PR exists
        if: always() && steps.claude.outputs.branch_name != ''
        env:
          GH_TOKEN: ${{ github.token }}
          BRANCH: ${{ steps.claude.outputs.branch_name }}
          ISSUE: ${{ github.event.issue.number }}
          TITLE: ${{ github.event.issue.title }}   # via env, never pasted into the script
        run: |
          ahead=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$BRANCH" --jq .ahead_by) || ahead=0
          [ "$ahead" = "0" ] && { echo "No commits, nothing to propose."; exit 0; }

          existing=$(gh pr list --head "$BRANCH" --state all --json number --jq '.[0].number')
          [ -n "$existing" ] && { echo "Claude opened #$existing itself."; exit 0; }

          # Linear's bot comments the ticket ID (e.g. ENG-12) on synced issues
          linear_id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE/comments" \
            --jq '.[] | select(.user.login | test("linear"; "i")) | .body' \
            | grep -oE '\b[A-Z][A-Z0-9]+-[0-9]+\b' | head -n 1 || true)

          title="$TITLE"
          body="Closes #$ISSUE"
          if [ -n "$linear_id" ]; then
            title="[$linear_id] $TITLE"           # this is what links the PR in Linear
            body="$body"$'\n'"Fixes $linear_id"
          fi

          gh pr create --draft --base main --head "$BRANCH" --title "$title" --body "$body"

Two lessons hide in this step. First, anything that must happen every time goes in a script, not in the agent's instructions. Second, the Claude action revokes its own GitHub token as its last step, so later steps have to use GITHUB_TOKEN and the API. A step that reused the agent's token failed silently.

Step 7: The Guard, So Humans Own Payments

The deny list stops the agent from editing protected files, but it does not see everything. A shell command like eslint --fix can rewrite a file without going through the Edit tool. So the last layer judges the diff, however the change was made.

yaml
# .github/workflows/agent-guard.yml
name: Agent guard

on:
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  guard:
    name: Agent payment-code guard
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Check the PR stays out of payment code
        env:
          HEAD_BRANCH: ${{ github.head_ref }}
          BASE: origin/${{ github.base_ref }}
        run: |
          # Judge the PR with the guard and rules as they are on main,
          # never the PR's own copy, so a PR cannot weaken its own check.
          git show "$BASE:scripts/agent-guard.mjs" > "$RUNNER_TEMP/agent-guard.mjs"
          git show "$BASE:.github/agent-protected.txt" > "$RUNNER_TEMP/agent-protected.txt"
          node "$RUNNER_TEMP/agent-guard.mjs" "$RUNNER_TEMP/agent-protected.txt" "$BASE" HEAD
js
// scripts/agent-guard.mjs (core of it; no dependencies)
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";

const [listFile, base, head] = process.argv.slice(2);
const branch = process.env.HEAD_BRANCH ?? "";

// Human branches pass straight through: payment code is theirs to change.
if (!branch.startsWith("claude/")) process.exit(0);

const git = (...args) => execFileSync("git", args, { encoding: "utf8" });

const paths = [];
const patterns = [];
for (const raw of readFileSync(listFile, "utf8").split("\n")) {
  const line = raw.trim();
  if (!line || line.startsWith("#") || line.startsWith("model:")) continue;
  if (line.startsWith("content:")) patterns.push(new RegExp(line.slice(8).trim()));
  else paths.push(line);
}

const globToRegExp = (glob) =>
  new RegExp("^" + glob.split("**").map((part) =>
    part.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")
  ).join(".*") + "$");

const range = `${base}...${head}`;
const violations = [];

for (const file of git("diff", "--name-only", range).split("\n").filter(Boolean)) {
  const glob = paths.find((p) => globToRegExp(p).test(file));
  if (glob) violations.push(`${file}: protected path (${glob})`);

  // Check every added or removed line against the content patterns
  for (const line of git("diff", "-U0", range, "--", file).split("\n")) {
    if (!/^[+-]/.test(line) || /^(\+\+\+|---)/.test(line)) continue;
    const hit = patterns.find((p) => p.test(line.slice(1)));
    if (hit) violations.push(`${file}: matches ${hit}`);
  }
}

if (violations.length) {
  console.error("Agent PR touches human-only code:\n" + violations.join("\n"));
  process.exit(1);
}

The guard runs on every PR but passes straight away for branches that do not start with claude/, so you can make it a required check without ever blocking your own work. And it always runs the copy of the script and list that is on main, so a PR cannot loosen the check that judges it. Changing what is protected is a normal, human-reviewed PR.

That gives three layers, each stronger than the last:

  1. Ask: CLAUDE.md tells the agent to stop and comment if a ticket needs payment code.
  2. Block: the deny list makes those files impossible to edit.
  3. Refuse: the guard fails any agent PR that touches them, and does not depend on the agent behaving at all.

Then test it on purpose. I gave the agent a “change the Starter price” ticket, and it stopped and posted a checklist for me to do by hand. Then I pushed a hand-made claude/guard-test branch that touched a pricing file, and the guard failed with the exact file and rule. Untested safety is a hope.

Step 8: Review and Merge

When the ticket hits In Review, read the agent's summary first: how it read the ticket, what it verified, and what it could not check. Agents on a runner cannot click through your UI, so for anything visual, check it out locally with gh pr checkout and look. Then either comment @claude change X on the PR, or mark it ready and merge once every check is green. Make the test gate and the guard required checks on main, so nothing red can be merged by accident.

What It Costs

Linear's free plan covers a small team. A private repo gets 2,000 free Actions minutes a month, and an agent run takes a few minutes for small tickets and up to about twenty for real work, so that covers over a hundred tickets. The Claude usage comes from your subscription and is shared with your own Claude Code sessions, so many parallel runs use the limit up faster.

Takeaways

The interesting part of AI-native delivery is not the agent. It is everything around it: a ticket that is specific enough to act on, a runner with no secrets worth stealing, checks the agent has to pass like everyone else, a hard line around the code where a mistake costs real money, and a human who reads the diff before it ships.

Agents are good at well-specified tickets. Humans are still the ones who decide what is well specified, and what is too important to delegate.