AI-Native Delivery: Claude Code Agents, Linear and GitHub Actions
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
- A ticket is written in Linear. Linear's GitHub sync mirrors it to a GitHub issue, both ways.
- Adding the
claudelabel (in Linear or GitHub) syncs to the issue and starts a workflow. - A fresh runner checks out the repo, starts a throwaway database, and runs Claude Code against the ticket.
- The agent writes code and tests, runs typecheck, lint and tests, pushes a
claude/issue-Nbranch, and opens a draft PR. - CI runs on the PR: the normal test gate, plus a guard that fails if the agent touched protected code.
- 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.
# 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.
# .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.
# .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 tomainand 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
ifis 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.
## 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.
# 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.
# .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
// 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:
- Ask: CLAUDE.md tells the agent to stop and comment if a ticket needs payment code.
- Block: the deny list makes those files impossible to edit.
- 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.