I recently set up a system where three specialized Claude Code agents run autonomously every day via GitHub Actions. Each one audits a different aspect of our codebase (performance, accessibility, and security) and opens a PR if it finds something worth fixing.
The idea is simple. The execution was not.
Here's everything I learned getting anthropics/claude-code-action@v1 to work reliably in CI, so you don't have to hit the same walls.
The Setup
The architecture is straightforward: a single workflow file with three parallel jobs, each running a Claude Code agent with a focused prompt. The prompts live in .github/prompts/ as markdown files, and the workflow just tells Claude to read and follow them.
name: Claude Code Agents
on:
schedule:
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
agents:
description: "Which agents to run (comma-separated or 'all')"
required: false
default: "all"
type: string
Each job follows the same pattern: checkout, setup node, clean up local configs (more on this later), and run the agent. Simple enough in theory.
Lesson 1: The OAuth Token vs API Key Distinction
If you're on a Claude Max plan and you run claude setup-token, you get an OAuth token. This is not the same as an Anthropic API key (the sk-ant-... kind you get from console.anthropic.com).
The action has two separate inputs for these:
# If you have a Console API key:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# If you have a Claude Code OAuth token (Max plan):
claude_code_oauth_token: ${{ secrets.ANTHROPIC_API_KEY }}
Use the wrong input and you'll get Invalid API key - Fix external API key with zero cost and zero tokens used. The error is clear once you see it, but getting to that error message required its own debugging journey.
Lesson 2: MCP Servers Will Crash Your CI
If your repo has a .mcp.json file (for local MCP servers like Supabase, PostHog, etc.), the action will try to start those servers in CI. They will fail. Claude Code will crash with exit code 1 and dump a wall of minified JavaScript that tells you absolutely nothing.
Here's the fun part: the action hardcodes enableAllProjectMcpServers = true after merging any settings you provide. So this does nothing:
# This gets overwritten by the action. Don't bother.
settings: '{"enableAllProjectMcpServers": false}'
I found this by reading the action's source code:
// Always set enableAllProjectMcpServers to true
settings.enableAllProjectMcpServers = true;
The fix is to delete .mcp.json from the checkout before the agent runs:
- name: Clean local dev configs
run: |
rm -f .mcp.json
rm -f .claude/settings.local.json
Lesson 3: Local Settings Will Also Crash Your CI
Your .claude/settings.local.json likely references MCP tools, local file paths, and tool permissions that only make sense on your machine. The action loads project settings via settingSources: ["user", "project", "local"], so this file gets picked up in CI.
If it references tools like mcp__supabase__execute_sql or paths like /Users/you/Library/Android/sdk/..., Claude Code will choke on them during initialization. Same symptom: exit code 1, minified JS dump, zero tokens used.
Remove it in CI alongside .mcp.json.
Lesson 4: Tool Availability != Tool Permissions
This one cost me the most time. The action passes allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] to the SDK, which tells Claude Code which tools are available. But Claude Code has a separate internal permission system that controls whether those tools can be used without approval.
In CI, there's nobody to click "approve" when Claude wants to write a file. Without explicit permissions, every Edit and Write call fails with:
Error: Claude requested permissions to write to .../file.tsx,
but you haven't granted it yet.
The fix is to pass permissions via the settings input:
settings: '{"permissions": {"allow": ["Edit", "Write", "Read", "Bash(*)", "Glob", "Grep"], "deny": []}}'
Note Bash(*) with the wildcard. Plain Bash won't cover commands with arguments.
Lesson 5: Re-running a Workflow Uses the Old Code
When you re-run a failed workflow in GitHub Actions, it uses the workflow file from the original commit that triggered the run. If you've pushed a fix to the workflow file since then, the re-run won't pick it up.
You need to trigger a completely new run:
gh workflow run claude-agents.yml --field agents=bolt
I wasted a few iterations re-running the same broken workflow wondering why my fixes weren't taking effect.
Lesson 6: Enable Full Output Early
By default, the action hides Claude's output for security. When things go wrong, you see minified JavaScript stack traces instead of actual error messages. Add this from day one:
show_full_output: true
This is what let me finally see the Invalid API key and permissions not granted messages instead of guessing from AJV schema validation dumps.
Lesson 7: The id-token Permission
The action needs OIDC tokens for authentication. Without this permission, you get a clear error, but it's easy to miss when setting up:
permissions:
contents: write
pull-requests: write
issues: write
id-token: write # Required for authentication
The Final Working Workflow
After all the debugging, here's what a working job looks like:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Clean local dev configs
run: |
rm -f .mcp.json
rm -f .claude/settings.local.json
- name: Run agent
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.ANTHROPIC_API_KEY }}
show_full_output: true
settings: '{"permissions": {"allow": ["Edit", "Write", "Read", "Bash(*)", "Glob", "Grep"], "deny": []}}'
prompt: |
Read the file .github/prompts/your-agent.md for your full instructions.
Follow every instruction in that file precisely.
The current repository is: ${{ github.repository }}
The default branch is: ${{ github.ref_name }}
The key pieces that aren't obvious from the docs:
- Delete
.mcp.jsonand.claude/settings.local.jsonbefore running - Use
claude_code_oauth_token(notanthropic_api_key) if you're on a Max plan - Pass
permissions.allowinsettingsto auto-approve tool usage - Set
show_full_output: trueso you can actually debug failures - Always trigger new runs instead of re-running old ones after workflow changes
Structuring Agent Prompts
One pattern that worked well: keep the prompt file self-contained. The workflow just says "read this file and follow it." The prompt file handles everything: discovering the repo structure, installing dependencies, running the audit, deciding whether to open a PR, and formatting the branch name and PR body.
This means the workflow file is identical across all your repos. The agents adapt at runtime by reading CLAUDE.md, detecting which directories exist, and adjusting their approach accordingly.
Was It Worth It?
The debugging was painful. Seven iterations to get from "action installed" to "PR opened." But now I have three agents that autonomously review every repo daily and surface real improvements. The first successful run found a missing useMemo in a component that was re-computing a filtered list on every render.
Not every run will produce a PR, and that's by design. The agents are instructed to only open one when there's a genuine, verified improvement. No busywork PRs, no trivial changes.
If you're setting this up yourself, save this post. You'll hit at least three of these issues, and the error messages won't help you.