Automation — headless, schedules, links and CI
Getting Claude Code to work when nobody is typing: claude -p in a script, the three scheduling mechanisms that are genuinely different, claude-cli:// links in a runbook, GitHub Actions and the Agent SDK.
Chapter 9 | Automation Written for: anyone who wants Claude Code working with nobody sitting at the keyboard
9.1 Headless — calling it from a script with claude -p
Add -p (or --print) to run non-interactively:
claude -p "Find and fix the bug in auth.py" --allowedTools "Read,Edit,Bash"
It reads stdin, so it pipes like any other Unix command:
cat build-error.txt | claude -p 'concisely explain the root cause of this build error' > output.txt
Piped stdin is capped at 10MB. Beyond that Claude Code exits with an error and a non-zero status. For larger input, write it to a file and name the path in the prompt.
--bare is the flag anyone running CI should know about
claude --bare -p "Summarize README.md" --allowedTools "Read"
It skips auto-discovery of hooks, skills, custom commands, subagents, plugins, MCP servers, auto memory and CLAUDE.md. The benefit is the same result on every machine: a hook in a teammate’s ~/.claude, or an MCP server in the project’s .mcp.json, will not run, because bare mode never reads them.
The reverse of that is a security warning worth reading twice. Without --bare, a -p session runs the hooks in a project’s .claude/settings.json and connects the servers in its .mcp.json even in a folder you have never trusted — a -p session shows no workspace trust dialog and no per-server approval prompt.
In bare mode Claude Code never reads OAuth credentials or the keychain, so set ANTHROPIC_API_KEY yourself.
The documentation states that
--barewill become the default for-pin a future release. Write today’s scripts with that in mind.
Output formats
| Value | What you get |
|---|---|
text (default) | Plain text |
json | Structured JSON with result, session ID and metadata, including total_cost_usd |
stream-json | Newline-delimited JSON, streaming live |
You can constrain the shape of the answer with --json-schema; the result lands in a structured_output field:
claude -p "Extract the main function names from auth.py" \
--output-format json \
--json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}'
Permission modes under -p — this differs from what many people assume. The starting mode for -p is Manual on every plan. You have to name the mode you want:
| Mode | Suits |
|---|---|
--permission-mode auto | Letting the classifier review actions instead of you |
--permission-mode dontAsk | Locked-down CI: deny anything not in an allow rule |
--permission-mode acceptEdits | Writing files without prompting |
And when nobody is available to answer a prompt at all:
claude -p "Update the dependency pins and run the tests" --permission-mode auto --permission-prompts none
Write allow rules precisely. The space before * carries meaning:
claude -p "Look at my staged changes and create an appropriate commit" \
--allowedTools "Bash(git diff *),Bash(git log *),Bash(git status *),Bash(git commit *)"
Bash(git diff *) allows commands beginning with git diff. Bash(git diff*), without the space, would also catch git diff-index.
Continuing a conversation
session_id=$(claude -p "Start a review" --output-format json | jq -r '.session_id')
claude -p "Continue that review" --resume "$session_id"
Exit status. The documentation states that Claude Code exits 0 on success and non-zero when the run fails, which is what scripts should branch on. The one specific case it names: SIGTERM produces exit code 143, and the turn in progress is left with no result recorded. Send SIGINT instead if you want the turn to finish.
9.2 Scheduling — three mechanisms that are not the same
This is where people pick wrong, because all three sound alike.
| Routines (cloud) | Desktop scheduled task | /loop | |
|---|---|---|---|
| Runs on | The cloud | Your machine | Your machine |
| Machine must be on | No | Yes | Yes |
| Session must be open | No | No | Yes |
| Survives a restart | Yes | Yes | Restored on --resume if unexpired |
| Local file access | No (fresh clone) | Yes | Yes |
| Permission prompts | None; runs autonomously | Configurable per task | Inherited from the session |
| Shortest interval | 1 hour | 1 minute | 1 minute |
/loop — the fastest way to watch something during a session
/loop 5m check if the deployment finished and tell me what happened
It takes three forms:
| What you give it | What happens |
|---|---|
| An interval and a prompt | Runs on a fixed schedule |
| A prompt only | Claude picks the interval each round, between one minute and one hour, based on what it observed |
Nothing at all (/loop) | Runs the built-in maintenance prompt, or your loop.md if you have one |
Units are s, m, h, d. Seconds round up to a minute, since cron’s finest granularity is one minute, and intervals that do not divide cleanly such as 7m or 90m are rounded to one that does — Claude tells you which it chose.
Press Esc while a self-paced loop is waiting to stop it.
Three traps worth knowing
- Seven-day expiry. A recurring task fires one last time and deletes itself seven days after creation. That ceiling exists so a forgotten loop cannot run forever. For anything longer, use Routines or a Desktop scheduled task.
- There is jitter. A recurring task can fire up to 30 minutes late (or up to half the interval, for tasks more frequent than hourly), so every session does not hit the API at the same moment. If timing matters, avoid
:00and:30— use3 9 * * *rather than0 9 * * *. - There is no catch-up. If the scheduled time passes while Claude is busy, it fires once when free, not once per missed round.
Write your own default prompt with loop.md
| Path | Scope |
|---|---|
.claude/loop.md | Project level; wins when both exist |
~/.claude/loop.md | User level; applies to projects with none of their own |
Edits take effect on the next iteration, so you can refine it while a loop runs. Content beyond 25,000 bytes is truncated.
The tools underneath are CronCreate, CronList and CronDelete, taking standard five-field cron expressions — minute hour day-of-month month day-of-week. A session holds at most 50 tasks, and extended syntax such as L, W, ? or name aliases like MON is not supported.
All times are interpreted in your local timezone, not UTC. Disable the whole scheduler with CLAUDE_CODE_DISABLE_CRON=1.
Routines — scheduling that does not need your machine on
Type /schedule in any session to create one conversationally; /schedule list and /schedule update also exist. Routines run on Anthropic-managed infrastructure, with a minimum interval of one hour, and require a claude.ai account (see the plan table in chapter 8).
9.3 claude-cli:// links — a session from one click
The format is claude-cli://open followed by parameters:
| Parameter | Meaning |
|---|---|
q | Text to pre-fill in the prompt box. URL-encode it; use %0A for line breaks. Maximum 5,000 characters |
cwd | Absolute path for the working directory. UNC paths, paths containing .., and invisible control characters are rejected |
repo | A GitHub owner/name slug. Claude Code resolves it to a local clone it has seen before; with no match it opens your home directory |
If you pass both cwd and repo, cwd wins and repo is ignored, even when that path does not exist.
In an incident runbook:
## High 5xx rate on web-gateway
1. Acknowledge the page in PagerDuty.
2. [Open Claude Code in the gateway repo](claude-cli://open?repo=acme/web-gateway&q=5xx%20rate%20is%20elevated%20on%20web-gateway.)
3. Post initial findings in #incident.
From a shell:
# macOS
open "claude-cli://open?repo=acme/payments&q=review%20open%20PRs"
# Linux
xdg-open "claude-cli://open?repo=acme/payments&q=review%20open%20PRs"
On Windows PowerShell use Start-Process "claude-cli://open?...". In cmd.exe, start treats its first quoted argument as a window title, so pass an empty one first: start "" "claude-cli://open?...".
On safety: the link runs nothing by itself. It only picks a directory and fills the prompt box; nothing reaches the model until you press Enter. A Prompt from an external link warning stays below the input until you send or clear it, and for prompts over 1,000 characters it includes the character count and tells you to scroll through the whole text first, since a long prompt can push instructions off screen.
Common traps
- Clicking does nothing — the handler is not registered yet. It registers when you send your first prompt of an interactive session, not when a session starts
- GitHub strips the link — READMEs, issues, PRs and wikis allow only
httpandhttps, so the link renders as bare text. Put it in a code block for people to copy instead - It opens your home directory —
repoonly resolves to clones Claude Code has already seen. Runclaudein that clone once, or switch tocwd
Turn registration off entirely with disableDeepLinkRegistration set to "disable" in settings.json.
9.4 CI — GitHub Actions
The quickest setup is /install-github-app inside Claude Code, which installs the GitHub App, adds the secret and prepares the workflow pull request for you.
Or write the workflow yourself:
name: Claude Code
on:
issue_comment:
types: [created]
jobs:
claude:
if: contains(github.event.comment.body, '@claude')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- uses: actions/checkout@v6
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
CLI flags go through claude_args — --model, --allowedTools, --max-turns and so on.
9.5 The Agent SDK
When you need more control than the CLI gives, use the packages directly. The old name “Claude Code SDK” is now “Agent SDK”.
| Language | Package |
|---|---|
| Python | claude-agent-sdk |
| TypeScript | @anthropic-ai/claude-agent-sdk |
from claude_agent_sdk import query, ClaudeAgentOptions
async for message in query(
prompt="your prompt",
options=ClaudeAgentOptions(
allowed_tools=["Read", "Edit", "Glob"],
permission_mode="acceptEdits",
),
):
...
9.6 Choosing between them
| If the problem is | Use |
|---|---|
| A one-off call from a script or Makefile | claude -p --bare |
| Watching something while you work | /loop |
| Regular work that must run with the machine off | Routines (/schedule) |
| Regular work that needs local files | A Desktop scheduled task |
| Something tied to a PR or a push | GitHub Actions |
| One button in an incident runbook | A claude-cli:// link |
| An application that steers message by message | The Agent SDK |
Glossary for this chapter
| Term | Meaning |
|---|---|
Headless / -p | Running non-interactively, reading stdin and writing stdout |
--bare | Skipping hooks, skills, MCP and CLAUDE.md so every machine gives the same result |
| Jitter | The deliberate offset applied to scheduled fire times to spread load |
| Routine | A scheduled task running in the cloud, needing no machine of yours |
| Deep link | A claude-cli:// URL that opens a session with the prompt pre-filled |