How to Automate Claude Code Tasks Safely
Headless runs, machine-readable output, tool lists decided in advance, hard limits on turns and spend, and a GitHub workflow that ends in a pull request a person reviews. The pieces, and the guardrails that make them safe to leave alone.
7 min read
To automate a Claude Code task, run it non-interactively with claude -p "<prompt>", ask for output your script can parse with --output-format json, list the tools it may use with --allowedTools, and cap the run with --max-turns and --max-budget-usd. In GitHub, the same run is wrapped by Anthropic’s Claude Code GitHub Action. Safety comes from three decisions made before the run starts rather than during it: what it may touch, how long it may go on, and where its result lands for a person to review. Nothing an automated run produces should reach your main branch, or your users, without that review.
The building block: claude -p
Add -p (or --print) to any claude command and it runs once, prints the result and exits. Anthropic’s guide to running Claude Code programmatically (opens in a new tab) is the reference: it exits with code 0 on success and non-zero when the run fails, so a script can branch on it, and it reads standard input, so you can pipe a log or a diff straight in.
# Explain a failed build and keep the answer cat build-error.txt | claude -p 'Explain the root cause of this build error in three lines' > cause.txt # Review a diff without giving Claude a shell at all git diff main | claude -p 'List likely bugs in this diff as file:line and one sentence each'
The second command is worth copying for its shape. Piping the diff in means Claude needs no permission to run git, so the review can run with no tools beyond reading. The smaller the job, the fewer tools it needs, and the easier it is to trust.
By default a -p run loads the same context an interactive session would: your CLAUDE.md, hooks, skills, subagents and MCP servers. Add --bare to skip all of that and get the same behaviour on every machine. Bare mode does not use your subscription login, so it needs ANTHROPIC_API_KEY in the environment, and you pass anything it should load explicitly, for example MCP servers with --mcp-config.
Ask for output a script can read
--output-format takes text (the default), json or stream-json. With json the answer is in a result field beside the session ID, usage and an estimated total_cost_usd. Add --json-schema with a JSON Schema and the answer arrives, validated, in structured_output, which is what you want when the next step is code rather than a person.
claude -p 'Check whether the changelog mentions every migration in db/migrations' \
--output-format json \
--json-schema '{"type":"object","properties":{"missing":{"type":"array","items":{"type":"string"}}},"required":["missing"]}' \
| jq '.structured_output.missing'stream-json prints one JSON event per line as the run goes, ending with a result message. Use it when something watches the run live; for a job whose output is read afterwards, json is simpler. Keep the session_id from either: claude -p "..." --resume <id> continues the same conversation if a later step needs a follow-up.
Decide the permissions before it starts
Nobody is there to answer a permission prompt, so the answers are given up front. --allowedTools lists what may run without asking, in the same rule syntax as settings: Read, Edit, Bash(npm test *), or an MCP tool by its full name such as mcp__fenbs__fenbs_comment. --disallowedTools removes or denies tools outright. --permission-mode dontAsk denies anything else that would have prompted, instead of waiting for an answer that will never come.
- Grant commands by prefix, not by tool.
Bash(npm test *)lets it run the tests;Bashlets it run anything your runner can. - Prefer piping input to granting a command. If the job needs a diff, a log or an issue body, fetch it in the script and pipe it in.
- Keep edits and pushes apart. A job that edits files does not also need to push; let the workflow around it decide what happens to the changes.
- Treat the checkout as untrusted. The headless guide is plain that a
-prun shows no workspace trust dialog, and runs the repository’s own hooks and connects its.mcp.jsonservers without asking. On code from someone else’s pull request, use--bare, or--strict-mcp-configto load only the servers you pass.
The same flags are what make a scheduled run safe; the cron and Desktop side of that is covered in Claude Code scheduled tasks, and the controls that belong around any coding agent, from deny rules to sandboxes, in AI coding agent security controls.
Put a ceiling on turns, money and time
An automated run that goes in circles costs money and proves nothing. Three limits stop it, and it is worth setting all three:
--max-turns 20ends the run with an error after that many agentic turns. The CLI reference (opens in a new tab) marks it print mode only, with no limit by default.--max-budget-usd 2.00stops the run once estimated API spend reaches the cap, subagents included. Print mode only.- A timeout outside Claude Code:
timeout-minuteson the GitHub job, ortimeoutin a shell script. If you stop a run with SIGTERM it exits with code 143 and records no result for the unfinished turn, so treat that exit code as “stopped”, not “done”. How to stop runs by hand is in how to stop a Claude Code task.
In GitHub: the Claude Code Action
For GitHub repositories, Anthropic maintains anthropics/claude-code-action. Run /install-github-app inside Claude Code for the guided set-up, which installs the Claude GitHub App, stores the credential as a repository secret and opens a pull request with the workflow. According to the Claude Code GitHub Actions documentation (opens in a new tab), the action has two modes. Without a prompt input it waits for someone to mention @claude in an issue or pull request. With one, it runs on whatever event triggers the workflow, and a plain prompt gets no shell or GitHub access until you grant tools in claude_args.
name: Explain failing tests
on:
workflow_dispatch:
jobs:
explain:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: "Run the unit tests. For each failure, give the test, the likely cause and the file to look at. Change nothing."
claude_args: |
--max-turns 20
--allowedTools "Read,Grep,Glob,Bash(npm test *)"The action checks who triggered it: on issue and pull request events the person must have write access to the repository, and bot accounts are refused unless you list them, which keeps two bots from triggering each other in a loop. On a public repository, GitHub withholds secrets from runs triggered by pull requests from forks, so those runs cannot authenticate at all.
Review before anything merges
The documentation’s own advice is short: grant the workflow only the permissions it needs, and review Claude’s changes before merging. In practice that means automated work lands on a branch and reaches main only through a pull request a person approves, enforced by a branch rule rather than by habit. The action’s security notes (opens in a new tab) describe what it does with the permissions it holds; read them before you widen claude_args.
One trap is worth knowing. GitHub does not start workflows for commits pushed with the default GITHUB_TOKEN, so if you pass that token to the action, your own CI will not run on Claude’s commits. Leave github_token out and the action authenticates as the Claude GitHub App, and your checks run as they would for anyone else. A review is only as good as the checks it can see; verifying AI-generated work covers what to look for.
Record the result where people already look
A run log is where an automated job’s findings go to be ignored. Put the outcome on the task it concerns instead. fenbs is reached over MCP; its REST API is not available yet. A browser sign-in is no use on a runner, so issue a token by hand under Settings, “Connect an AI assistant”: name it after the job, tick read and comment, and set an expiry. Store it as a secret and pass it as a header. The board checks the token’s scopes on its side of the connection, whatever the prompt says.
claude_args: |
--max-turns 20
--mcp-config '{"mcpServers":{"fenbs":{"type":"http","url":"https://fenbs.ai/api/mcp","headers":{"Authorization":"Bearer ${{ secrets.FENBS_TOKEN }}"}}}}'
--allowedTools "Read,Grep,Glob,Bash(npm test *),mcp__fenbs__fenbs_get_item,mcp__fenbs__fenbs_comment"Then end the prompt with the card: “Comment your findings on BUG-042, starting with ‘CI explain:’.” Every comment appears in the board’s history as the assistant acting for you, and the prefix says which job wrote it. If the job should file new tasks as well, tick write and have it pass key to fenbs_create_item: the same key never files twice, so a nightly job that meets the same failure every night keeps one task instead of opening seven. Revoke the token the day the job is retired.
Related
What each scope allows: assistant tokens and scopes. Connecting Claude Code by sign-in or token: Claude Code integration and the MCP docs. A reminder that fires when work finishes without a board update: Claude Code hooks.