Module 14 — Headless mode, GitHub Actions CI, and the Agent SDK
This module takes the keyboard away: Claude Code runs in a nightly cron, a GitHub Actions PR, and a forty-line Python script. Three surfaces, one rule — the prompt and the tool palette are the contract, everything else is configuration.
claude -p: the CLI in non-interactive mode
claude -p "…" (alias --print) runs a request without an interactive session. headless.md and cli-reference.md set the exact flags; a useful CI selection:
| Flag | Role |
|---|---|
-p, --print | Renders the output and exits. Incompatible with --bg and with --cloud "task" |
--bare | Skips hooks, skills, commands, subagents, plugins, MCP, auto memory, CLAUDE.md — leaves Bash, Read, Edit. Recommended in CI |
--output-format text|json|stream-json | Text, JSON (result, session_id, metadata), or line-by-line JSON |
--include-partial-messages | Streaming deltas; requires --print --output-format stream-json --verbose |
--verbose | Turn-by-turn logging |
--forward-subagent-text | Re-emits subagent text and thoughts (v2.1.211+) |
--json-schema '{…}' | Enforces a JSON schema, fills structured_output |
--max-turns <N> | Turn cap in print, fails on overflow |
--continue, -c / --resume, -r <id|name> | Resume the last conversation / a specific session |
--no-session-persistence | Do not persist the session on disk (print only) |
--allowedTools "Bash,Read,Edit" (alias --allowed-tools) | Auto-approve according to the permission rule syntax |
--disallowedTools "…" | Remove a tool ("Edit") or deny a pattern (Bash(rm *)) |
--tools "…" | Restrict the built-in palette; "" disables everything, "default" restores everything |
--permission-mode default|acceptEdits|plan|auto|dontAsk|bypassPermissions | Initial mode. Under -p, default is Manual on every plan |
--permission-prompts host|none | Who answers the requests; none refuses without an operator (v2.1.259+) |
--permission-prompt-tool mcp_x | Delegates requests to an MCP tool |
--append-system-prompt "…" / --append-system-prompt-file | Appends text to the default system prompt |
--system-prompt "…" / --system-prompt-file | Fully replaces the system prompt |
--mcp-config <file|json> | Loads MCP servers; waits for their connection up to MCP_TIMEOUT (30 s) |
--add-dir <path> | Adds a folder to the read/edit scope |
--model <alias> | sonnet, opus, haiku, fable, or full name |
--effort low|medium|high|xhigh|max|ultracode | Session effort level |
--agents '{"reviewer":{…}}' | Defines subagents dynamically in JSON |
Two key moves in CI: pipe stdin (capped at 10 MB) and read the exit code (0 success, non-zero failure, 143 on SIGTERM). --bare is essential: without it, -p loads hooks, MCP, and the current directory's CLAUDE.md without a trust dialog. --output-format json fills total_cost_usd and a per-model breakdown — useful for tracking spend without going through /usage.
cat build-error.txt | claude --bare -p 'root cause in one sentence' \
--output-format json --max-turns 3 --allowedTools "Read" > diag.json
The triage.sh script: sort issues overnight
Kiosque receives dozens of poorly labeled issues per week. A 3 a.m. cron delegates the triage to Claude, using only the official corpus: -p, --bare, --output-format json, --json-schema, --allowedTools, --permission-mode dontAsk, --max-turns.
#!/usr/bin/env bash
# scripts/triage.sh — classifies open issues
set -euo pipefail
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:?missing}"
for id in $(gh issue list --state open --json number --jq '.[].number'); do
body=$(gh issue view "$id" --json title,body --template '{{.title}}\n\n{{.body}}')
echo "$body" | claude --bare -p \
"classify this issue: bug|feature|question|duplicate. propose 1 to 3 labels and a priority p1|p2|p3." \
--output-format json --max-turns 2 \
--permission-mode dontAsk --allowedTools "Read" \
--json-schema '{"type":"object","required":["kind","priority","labels"],
"properties":{"kind":{"enum":["bug","feature","question","duplicate"]},
"priority":{"enum":["p1","p2","p3"]},
"labels":{"type":"array","items":{"type":"string"}}}}' \
| jq -r '.structured_output | @json' \
| xargs -I{} gh issue edit "$id" --add-label "$(echo {} | jq -r '.labels|join(",")')" \
--add-label "$(echo {} | jq -r '.kind + \"/\" + .priority')"
done
--permission-mode dontAsk refuses anything not authorized, --max-turns 2 caps the per-issue cost, --json-schema guarantees a usable object for gh.
GitHub Actions and @claude
github-actions.md describes anthropics/claude-code-action@v1. Quick install: /install-github-app from Claude Code installs the GitHub app, stores a secret (ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN obtained through claude setup-token), and opens a PR with the workflow. By hand: install the app, add the secret, copy examples/claude.yml.
Two modes are auto-detected:
- Interactive (no
promptinput): Claude responds when@claudeis mentioned in the body/title of a new issue, a PR/issue comment, or a review comment. The author must havewriteaccess (unless inallowed_non_write_userswith a customgithub_token) and must not be a bot (unless inallowed_bots). - Automation (with
prompt): runs on any event,scheduleincluded. Default output in the log; to post on the PR, the prompt must ask for it and a tool must be able to post.
For Kiosque, .github/workflows/claude.yml responds to mentions and runs the review:
# .github/workflows/claude.yml
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_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
actions: read
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 1
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: >
--max-turns 8 --model claude-sonnet-5
--allowedTools "Bash(make test),Bash(ruff *),Read,Edit"
Non-boilerplate: id-token: write (app auth), actions: read (CI results), the if: guard (do not spin up a runner for nothing). claude_args accepts any cli-reference.md flag. For an automatic review, a second job invokes the code-review plugin with plugin_marketplaces, plugins, and prompt: "/code-review:code-review --comment …" — without --comment, findings stay in the logs. Cloud provider: three mutually exclusive inputs use_bedrock: "true", use_vertex: "true", use_foundry: "true", with OIDC. GitLab CI/CD follows a close model (image: node:24-alpine3.21, install through claude.ai/install.sh, then claude -p "${AI_FLOW_INPUT}" --permission-mode acceptEdits); in beta, maintained by GitLab.
Watch for CI cascades: if you pass github_token: ${{ secrets.GITHUB_TOKEN }} to the action, Claude's commits will not trigger any other workflow (GitHub's default block). Remove the line so Claude pushes as the Claude Code app — its commits will then trigger push and pull_request.
Scheduling: /loop, /goal, routines
/loop repeats a prompt at an interval within the session; formats are bare token (30m) or clause (every 2 hours), units s, m, h, d. With no prompt, Claude runs the built-in maintenance prompt (or .claude/loop.md / ~/.claude/loop.md). A recurring task expires after 7 days; Esc interrupts an iteration. scheduled-tasks.md distinguishes /loop, cloud routines (/schedule, claude.ai/code/routines), and Desktop tasks.
/goal <condition>: after each turn, Haiku by default evaluates whether the condition holds. Three verdicts: Not yet met, Met, Impossible. An effective condition names a measurable state, a proof, and a constraint (4,000 characters max). In non-interactive mode, claude -p "/goal …" runs until resolution; add --output-format stream-json --verbose, otherwise nothing displays until the end.
Agent SDK: the same loop, in a program
agent-sdk__overview.md states the equivalence: the Agent SDK exposes "the same tools, the same agent loop, the same context management as Claude Code," in two packages — claude-agent-sdk (Python 3.10+) and @anthropic-ai/claude-agent-sdk (Node 18+), bundling the native binary. Authentication through API key (ANTHROPIC_API_KEY, or CLAUDE_CODE_USE_BEDROCK=1, CLAUDE_CODE_USE_VERTEX=1, CLAUDE_CODE_USE_FOUNDRY=1).
Entry point: query(...), an async iterator that streams the messages — AssistantMessage, tool calls, ResultMessage. Useful Python options (agent-sdk__python.md): allowed_tools, disallowed_tools, system_prompt (free string, {"type": "preset", "preset": "claude_code", "append": "…"} or {"type": "file", "path": "…"}), mcp_servers, permission_mode, max_turns, model, cwd, add_dirs, env, hooks. Same API in camelCase on the TypeScript side. Structured output: JSON Schema as an argument, the response lands in structured_output.
release_notes.py: 40 lines of Python SDK
For Kiosque, on every tag, produce the notes from the commits. Reduced palette (Bash, Read), permission_mode: acceptEdits, max_turns to cap.
# scripts/release_notes.py
import asyncio
import json
from claude_agent_sdk import (
query, ClaudeAgentOptions, AssistantMessage, ResultMessage,
)
PROMPT = (
"generate the release notes for the current tag. use `git log`, "
"extract commits since the last tag, group into 'Features', "
"'Fixes', 'Internal' sections. reply in English, "
"no emojis. return strictly the final markdown."
)
OPTIONS = ClaudeAgentOptions(
allowed_tools=["Bash", "Read"],
permission_mode="acceptEdits",
max_turns=6,
system_prompt={"type": "preset", "preset": "claude_code",
"append": "notes must fit on one A4 page."},
)
async def main() -> None:
async for msg in query(prompt=PROMPT, options=OPTIONS):
if isinstance(msg, AssistantMessage):
for block in msg.content:
if hasattr(block, "text"):
print(block.text)
elif hasattr(block, "name"):
print(f"[tool] {block.name}")
elif isinstance(msg, ResultMessage):
print(f"--- end: {msg.subtype}")
if __name__ == "__main__":
asyncio.run(main())
Run it with uv run scripts/release_notes.py. In production, filter text blocks to publish automatically to the GitHub release.
SDK or -p
-p wins every time the prompt fits on a shell line and the result comes out as JSON usable by jq. The SDK becomes necessary to intercept each tool (canUseTool callback), manage several concurrent sessions in a single process, mix business logic with tool calls, or expose an HTTP service that resumes by ID.
Without --bare, claude -p loads hooks and MCP servers from .mcp.json with no trust dialog. In a cron, in Actions, in pre-commit: --bare by default, --allowedTools explicit, --permission-prompts none.
SIGTERM closes with exit code 143 without finishing the turn; SIGINT finishes the turn then quits; the SDK's interrupt() is the programmable equivalent. SessionEnd hooks run before closing. A background bash is terminated five seconds after the final result; a background subagent makes -p wait, capped at ten minutes (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS).
Summary
claude -p with --bare, --output-format json, and often --json-schema is the base of any reproducible automation; the flags --permission-mode dontAsk, --permission-prompts none, --allowedTools, and --max-turns lock down an unattended run. GitHub Actions offers interactive mode (@claude) and automation mode (prompt:) through anthropics/claude-code-action@v1 — claude_args accepts any CLI flag. /loop repeats at an interval, /goal targets a condition evaluated by a fast model, routines schedule outside the session. The Agent SDK Python and TypeScript expose the same loop through query(...) with allowed_tools, permission_mode, hooks, mcp_servers, system_prompt, and structured output through a JSON schema.
Next module: Master costs, context, security, and team deployment.