Claude Code Runner
The Claude Code runner invokes the claude CLI in non-interactive print mode (claude -p) with --output-format stream-json.
Requirements
- Claude Code CLI >= 2.1.0
- The
claudebinary must be on yourPATH(or specify a custom path)
How It Works
The runner spawns claude -p --output-format stream-json as a subprocess and parses the newline-delimited JSON stream. Each line is a typed message (system, assistant, tool_use, tool_result, result, error).
Runner-Specific Options
| Option | Type | Description |
|---|---|---|
allowedTools | string[] | Tools the agent may use |
disallowedTools | string[] | Tools the agent may not use |
tools | string[] | Exclusive built-in tool whitelist (see below) |
permissionMode | string | Permission mode for the run (see below) |
mcpConfig | string | Path to MCP server configuration file |
settings | string | Additional settings: file path or inline JSON (see below) |
jsonSchema | string | JSON Schema for structured output |
maxBudgetUSD | float | Cost limit in USD |
resume | string | Session ID to resume |
continue | bool | Continue the most recent session |
appendSystemPrompt | string | Appended to the default system prompt |
includePartialMessages | bool | Stream partial/incremental messages |
channelEnabled | bool | Enable two-way channel communication |
channelLogFile | string | File path for channel server logs |
channelLogLevel | string | Channel server log level (debug, info, warn, error) |
Stream Message Types
| Type | Description |
|---|---|
system | Init message with model, session_id, tools |
assistant | Text, thinking, or tool_use content |
user | Tool results |
result | Final result with cost, usage, duration |
stream_event | Raw API streaming events |
channel_reply | Reply from the agent to a channel message |
rate_limit_event | Rate limit notifications |
Examples
Simple Run
runner := claudecode.NewRunner()
result, err := runner.Run(ctx, "Summarize the README")const runner = createClaudeRunner();
const result = await runner.run("Summarize the README");runner = ClaudeRunner()
result = await runner.run("Summarize the README")With Tool Restrictions
result, err := runner.Run(ctx, "Read the config file",
claudecode.WithAllowedTools("Read"),
claudecode.WithDisallowedTools("Write", "Bash"),
)const result = await runner.run("Read the config file", {
allowedTools: ["Read"],
disallowedTools: ["Write", "Bash"],
});result = await runner.run("Read the config file", ClaudeRunOptions(
allowed_tools=["Read"],
disallowed_tools=["Write", "Bash"],
))Exclusive Tool Whitelist
allowedTools only pre-approves tools — unlisted built-ins remain registered and can still execute. tools maps to the CLI's --tools flag, which replaces the built-in tool set: every unlisted built-in is denied at registration, including built-ins introduced by future CLI versions. It acts independently of the permission system, so denied tools stay denied even with dangerouslySkipPermissions.
Pass "" to disable all tools, or "default" to restore the full built-in set. Omitting the option leaves the CLI default in place.
result, err := runner.Run(ctx, "Read the config file",
claudecode.WithTools("Read", "Grep"),
)const result = await runner.run("Read the config file", {
tools: ["Read", "Grep"],
});result = await runner.run("Read the config file", ClaudeRunOptions(
tools=["Read", "Grep"],
))Permission Mode
dangerouslySkipPermissions is all-or-nothing — it bypasses every prompt. permissionMode maps to the CLI's --permission-mode flag and selects a graduated mode instead. Known modes:
| Mode | Behavior |
|---|---|
default | Prompt for permission on first use of each tool |
acceptEdits | Auto-accept file edits, prompt for everything else |
auto | Auto-approve without prompting |
plan | Plan only — no tool execution |
dontAsk | Never prompt; deny anything not pre-approved |
bypassPermissions | Bypass all permission checks |
The value is passed through as a plain string rather than a closed enum, so new CLI modes work without a library upgrade.
When both options are set, permissionMode wins and --dangerously-skip-permissions is omitted. Setting only dangerouslySkipPermissions still passes the skip flag as before.
result, err := runner.Run(ctx, "Draft a refactor",
claudecode.WithPermissionMode("plan"),
)const result = await runner.run("Draft a refactor", {
permissionMode: "plan",
});result = await runner.run("Draft a refactor", ClaudeRunOptions(
permission_mode="plan",
))Additional Settings
settings maps to the CLI's --settings flag and loads extra configuration for a single run — permission blocks, hooks, env, and so on — without staging a whole CLAUDE_CONFIG_DIR. The value is either a path to a settings JSON file or a raw JSON string; it is passed through verbatim and the CLI disambiguates.
WARNING
In non-interactive (-p) mode the CLI silently ignores settings that fail validation — no error is raised and the run proceeds without them. Do not rely on a malformed path or JSON string surfacing an error; validate the value yourself if it matters.
result, err := runner.Run(ctx, "Audit this module",
claudecode.WithSettings(`{"permissions": {"deny": ["Bash"]}}`),
)const result = await runner.run("Audit this module", {
settings: `{"permissions": {"deny": ["Bash"]}}`,
});result = await runner.run("Audit this module", ClaudeRunOptions(
settings='{"permissions": {"deny": ["Bash"]}}',
))Session Resume
// First run
result, _ := runner.Run(ctx, "Start a task")
// Resume later
result, _ = runner.Run(ctx, "Continue",
claudecode.WithResume(result.SessionID),
)const result = await runner.run("Start a task");
const resumed = await runner.run("Continue", {
resume: result.sessionId,
});result = await runner.run("Start a task")
resumed = await runner.run("Continue", ClaudeRunOptions(
resume=result.session_id,
))Streaming with Partial Messages
session, _ := runner.Start(ctx, "Write a story",
claudecode.WithIncludePartialMessages(),
)
for msg := range session.Messages {
fmt.Print(msg.Text())
}const session = runner.start("Write a story", {
includePartialMessages: true,
});
for await (const msg of session.messages) {
const delta = messageTextDelta(msg);
if (delta) process.stdout.write(delta);
}session = runner.start("Write a story", ClaudeRunOptions(
include_partial_messages=True,
))
async for msg in session:
if msg.text:
print(msg.text, end="")