Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ ClaudeAgentSDK.query(prompt: "Create a profile for a software engineer", options
end
```

In the `output_format` Hash, `type` may be a String or a Symbol (`type: :json_schema`), and the keys Symbols or Strings.

See [examples/structured_output_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/structured_output_example.rb).

## Thinking Configuration
Expand Down Expand Up @@ -96,6 +98,8 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
)
```

In a Hash form, `type` may be a String or a Symbol (`type: :preset`).

`snapshot` is sent on the control-protocol `initialize` request (never as a CLI flag), so it applies to both `query()` and `Client`. When omitted it acts as `true`, except in bare mode (`bare: true`), where it acts as `false`. A `SystemPromptFile` has no `snapshot`.

Requires Claude Code CLI 2.1.257 or later. Before 2.1.265, a session with an `append` or custom prompt recorded it only when `snapshot` was `true`. See [Modifying system prompts](https://code.claude.com/docs/en/agent-sdk/modifying-system-prompts#change-the-prompt-of-an-existing-session) for details.
Expand Down Expand Up @@ -177,10 +181,18 @@ Available beta features are listed in the `SDK_BETAS` constant.
# Array of tool names
options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: ['Read', 'Edit', 'Bash'])

# The same names as one String, in the comma-separated form of the CLI's --tools flag
options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: 'Read,Edit,Bash')

# Preset
options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: ClaudeAgentSDK::ToolsPreset.new(preset: 'claude_code'))

# The preset as a Hash
options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: { type: 'preset', preset: 'claude_code' })
```

A String is passed to the CLI as written. In the Hash form, `type` may be a String or a Symbol (`type: :preset`).

## Skills

`skills` is the single place to enable skills for the main session — it auto-allows the `Skill` tool and defaults `setting_sources` to `['user', 'project']` (when unset) so skill files are discovered:
Expand Down Expand Up @@ -218,6 +230,24 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
)
```

`sandbox` also takes a Hash, and so do `network` and `filesystem`, inside that Hash or inside a `SandboxSettings`. A Hash may spell the fields of `SandboxSettings`, `SandboxNetworkConfig` and `SandboxFilesystemConfig` as the classes do (`denied_domains`) or as the CLI does (`deniedDomains`), with Symbol or String keys:

```ruby
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
sandbox: {
enabled: true,
network: { denied_domains: ['evil.example'] },
filesystem: { deny_read: ['~/.ssh'] }
}
)
```

- Any other key is sent as written. That is how to pass a sandbox setting the classes have no attribute for (`allowAppleEvents`, `strictAllowlist` inside `network`): spell it as the CLI does.
- A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for that key: `true` or `false` for a switch, an Array of Strings for a list, a port number (an Integer from 0 to 65535) for a proxy port, a Hash of String Arrays for `ignore_violations`. With a value of any other shape (`denied_domains: 'evil.example'`, a String where an Array belongs) the key is sent as written, and the CLI ignores a key it does not know.
- A field holding `nil` is left out, in either spelling, as a `nil` attribute of the typed classes is.

The second rule exists because of how the CLI validates these settings (observed with CLI 2.1.287). When one sandbox value fails its settings schema, the CLI discards the **whole** `--settings` value: the sandbox, and everything you passed in `settings:` next to it, `permissions` rules included. The CLI reports the failure in the `errors` of its `get_settings` control response, and the SDK does not surface that: `connect` succeeds, nothing appears on stderr, and the session runs unsandboxed. That applies to a value written under the CLI's own name (`excludedCommands: 'docker'`) and to the typed classes, which send each value as you gave it: `SandboxSettings.new(excluded_commands: 'docker')` leaves the session without a sandbox.

`enabled: true` asks for a sandbox. It does not make one a requirement. When the sandbox cannot start on the host (missing dependencies, an unsupported platform), the CLI carries on without it. Its settings schema describes the setting that decides this, `failIfUnavailable`, as follows (CLI 2.1.287):

> Exit with an error at startup if sandbox.enabled is true but the sandbox cannot start (missing dependencies or unsupported platform). When false (default), a warning is shown and commands run unsandboxed.
Expand Down
8 changes: 4 additions & 4 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Guides: [Structured Output](configuration.md#structured-output), [Thinking Confi
|-----------------|--------------|
| `nil` (the default) | `--system-prompt ""`: an empty prompt, not Claude Code's default one |
| a String, `SystemPromptCustom`, or `{ type: 'custom', prompt: '...' }` | `--system-prompt <text>` |
| `SystemPromptFile`, or `{ type: 'file', path: '...' }` | `--system-prompt-file <path>` |
| `SystemPromptFile`, or `{ type: 'file', path: '...' }` (the path a String or a `Pathname`) | `--system-prompt-file <path>` |
| `SystemPromptPreset`, or `{ type: 'preset', preset: 'claude_code' }` | no system prompt flag, so the CLI uses its default prompt; `append:` adds `--append-system-prompt <text>` |

`exclude_dynamic_sections` on a preset is sent as `initialize.excludeDynamicSections` ([Cross-User Prompt Caching](configuration.md#cross-user-prompt-caching)), and `snapshot` on a preset or a custom prompt as `initialize.systemPromptSnapshot` ([System Prompt Snapshot](configuration.md#system-prompt-snapshot)). Both are left out when unset.
Expand All @@ -79,7 +79,7 @@ Guides: [Tools Configuration](configuration.md#tools-configuration), [Skills](co

| Option | Type | Default | Sent to the CLI as |
|--------|------|---------|--------------------|
| `tools` | `Array[String] \| ToolsPreset \| Hash[Symbol \| String, untyped]` | `nil` | `--tools <comma-joined names>`. `[]` sends `--tools ""` (no built-in tools); a `ToolsPreset` sends `--tools default` |
| `tools` | `Array[String] \| ToolsPreset \| Hash[Symbol \| String, untyped] \| String` | `nil` | `--tools <comma-joined names>`; a String is sent as written, in the CLI's own comma-separated form. `[]` sends `--tools ""` (no built-in tools); a `ToolsPreset`, or `{ type: 'preset' }`, sends `--tools default` |
| `allowed_tools` | `Array[String]` | `[]` | `--allowedTools` (comma-joined; no flag when empty) |
| `disallowed_tools` | `Array[String]` | `[]` | `--disallowedTools` (comma-joined; no flag when empty) |
| `permission_mode` | `String` | `nil` | `--permission-mode` |
Expand All @@ -100,7 +100,7 @@ Guides: [Custom Tools (SDK MCP Servers)](mcp-servers.md), [Subagent capabilities
| `mcp_servers` | `Hash[String \| Symbol, mcp_server_config] \| String` | `{}` | `--mcp-config`: a Hash as `{"mcpServers": {...}}` JSON, a String (a file path or JSON) as given. An SDK server contributes only its `type` and `name`; its tools are served in your process over the control protocol |
| `strict_mcp_config` | `bool` | `false`; explicit `nil` stays `nil` | `--strict-mcp-config`. See [`strict_mcp_config`](#strict_mcp_config) |
| `agents` | `Hash[String \| Symbol, AgentDefinition \| Hash[Symbol \| String, untyped]]` | `nil` | `initialize.agents` |
| `plugins` | `Array[SdkPluginConfig \| Hash[Symbol \| String, untyped]]` | `nil` | `--plugin-dir <path>`, once per plugin |
| `plugins` | `Array[SdkPluginConfig \| Hash[Symbol \| String, untyped]]` | `nil` | `--plugin-dir <path>`, once per plugin. The path may be a String or a `Pathname` |

### `strict_mcp_config`

Expand All @@ -114,7 +114,7 @@ Guides: [Bare Mode](configuration.md#bare-mode), [Verbatim Prompts](configuratio

| Option | Type | Default | Sent to the CLI as |
|--------|------|---------|--------------------|
| `settings` | `String \| Hash[Symbol \| String, untyped]` | `nil` | `--settings`: a Hash as JSON, a String (a file path or JSON) as given. With `sandbox` set as well, one merged JSON value |
| `settings` | `String \| Pathname \| Hash[Symbol \| String, untyped]` | `nil` | `--settings`: a Hash as JSON, a String (a file path or JSON) as given, a `Pathname` as the path of a settings file. With `sandbox` set as well, one merged JSON value |
| `setting_sources` | `Array[String]` | `nil` | `--setting-sources` (comma-joined). `[]` sends `--setting-sources ""`; `nil` sends no flag |
| `add_dirs` | `Array[String \| Pathname]` | `[]` | `--add-dir`, once per directory |
| `bare` | `bool` | `nil` | `--bare` |
Expand Down
5 changes: 3 additions & 2 deletions lib/claude_agent_sdk.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ def self.extract_exclude_dynamic_sections(system_prompt)
eds = system_prompt.exclude_dynamic_sections
return eds if [true, false].include?(eds)
elsif system_prompt.is_a?(Hash)
type = system_prompt[:type] || system_prompt['type']
# The tag may be a Symbol (type: :preset), as CommandBuilder reads it.
type = (system_prompt[:type] || system_prompt['type']).to_s
if type == 'preset'
eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
return eds if [true, false].include?(eds)
Expand All @@ -163,7 +164,7 @@ def self.extract_system_prompt_snapshot(system_prompt)
snapshot = system_prompt.snapshot
return snapshot if [true, false].include?(snapshot)
when Hash
type = system_prompt[:type] || system_prompt['type']
type = (system_prompt[:type] || system_prompt['type']).to_s
if %w[preset custom].include?(type)
snapshot = system_prompt.fetch(:snapshot) { system_prompt['snapshot'] }
return snapshot if [true, false].include?(snapshot)
Expand Down
94 changes: 71 additions & 23 deletions lib/claude_agent_sdk/command_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def append_system_prompt(cmd)
when String
cmd.push('--system-prompt', @options.system_prompt)
when SystemPromptFile
cmd.push('--system-prompt-file', @options.system_prompt.path)
cmd.push('--system-prompt-file', path_string(@options.system_prompt.path))
when SystemPromptCustom
# The object form of a String prompt; snapshot travels on the
# initialize request, not as a CLI flag.
Expand All @@ -90,12 +90,25 @@ def append_system_prompt(cmd)
end
end

# The type tag of a Hash option, as a String: `type: :preset` is the
# natural Ruby spelling of `type: 'preset'`, and thinking and the MCP
# server configs already read it that way. A missing tag is '', which
# matches no branch, like any other tag the SDK does not know.
def hash_type(hash)
(hash[:type] || hash['type']).to_s
end

# A path as the String the command line takes. A Pathname (anything that
# answers #to_path) is converted; every other value is returned as it is.
def path_string(path)
path.respond_to?(:to_path) ? path.to_path : path
end

def append_hash_system_prompt(cmd, prompt_hash)
prompt_type = prompt_hash[:type] || prompt_hash['type']
case prompt_type
case hash_type(prompt_hash)
when 'file'
prompt_path = prompt_hash[:path] || prompt_hash['path']
cmd.push('--system-prompt-file', prompt_path) if prompt_path
cmd.push('--system-prompt-file', path_string(prompt_path)) if prompt_path
when 'custom'
prompt = prompt_hash.fetch(:prompt) { prompt_hash['prompt'] }
cmd.push('--system-prompt', custom_prompt_text(prompt))
Expand Down Expand Up @@ -306,16 +319,14 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo
settings_is_path = false

if @options.settings
if @options.settings.is_a?(String)
if @options.settings.respond_to?(:to_path)
# A Pathname names a settings file; it is never tried as inline JSON.
settings_hash, settings_is_path = settings_file(cmd, @options.settings.to_path)
elsif @options.settings.is_a?(String)
begin
settings_hash = JSON.parse(@options.settings)
rescue JSON::ParserError
if @options.sandbox.nil? # rubocop:disable Metrics/BlockNesting -- settings-is-a-path fallback inside the JSON parse rescue
settings_is_path = true
cmd.push('--settings', @options.settings)
else
settings_hash = load_settings_file(@options.settings)
end
settings_hash, settings_is_path = settings_file(cmd, @options.settings)
end
elsif @options.settings.is_a?(Hash)
settings_hash = @options.settings.dup
Expand All @@ -327,12 +338,36 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo
# read from JSON spell that key as a String; left next to the Symbol
# key below it would be written twice (json 3.x raises on that).
settings_hash = settings_hash.reject { |key, _| key.to_s == 'sandbox' }
settings_hash[:sandbox] = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox
settings_hash[:sandbox] = sandbox_section(@options.sandbox)
end

cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty?
end

# --settings for a settings file, as [settings_hash, settings_is_path].
# Without a sandbox option the path itself is passed and the CLI reads
# the file. With one, the file is read here, so that the option can be
# folded into its content.
def settings_file(cmd, path)
return [load_settings_file(path), false] unless @options.sandbox.nil?

cmd.push('--settings', path)
[{}, true]
end

# The sandbox section as the CLI reads it. A Hash stands for the
# SandboxSettings with the same fields: the CLI only knows the camelCase
# keys that class writes, and it ignores the others without an error, so
# a Hash in Ruby spelling (deny_read, denied_domains) is renamed like the
# typed value would be (SandboxKeys). Booleans go out as they are.
def sandbox_section(sandbox)
case sandbox
when SandboxSettings then sandbox.to_h
when Hash then SandboxKeys.normalize(sandbox)
else sandbox
end
end

def append_budget(cmd)
cmd.push('--max-budget-usd', @options.max_budget_usd.to_s) if @options.max_budget_usd

Expand Down Expand Up @@ -417,10 +452,13 @@ def append_tools(cmd)
when Array
tools_value = @options.tools.empty? ? '' : @options.tools.join(',')
cmd.push('--tools', tools_value)
when String
# The CLI's own syntax ("Read,Grep", "default", ""): passed as written.
cmd.push('--tools', @options.tools)
when ToolsPreset
cmd.push('--tools', 'default')
when Hash
if (@options.tools[:type] || @options.tools['type']) == 'preset'
if hash_type(@options.tools) == 'preset'
cmd.push('--tools', 'default')
else
cmd.push('--tools', JSON.generate(@options.tools))
Expand All @@ -431,13 +469,7 @@ def append_tools(cmd)
def append_output_format(cmd)
return unless @options.output_format

schema = if @options.output_format.is_a?(Hash) && @options.output_format[:type] == 'json_schema'
@options.output_format[:schema]
elsif @options.output_format.is_a?(Hash) && @options.output_format['type'] == 'json_schema'
@options.output_format['schema']
else
@options.output_format
end
schema = output_schema(@options.output_format)
# A json_schema output_format with a nil/absent schema must skip the
# flag — `--json-schema null` is rejected by the CLI (Python guards
# `schema is not None`).
Expand All @@ -447,6 +479,22 @@ def append_output_format(cmd)
cmd.push('--json-schema', schema_json)
end

# The schema of a { type: 'json_schema', schema: ... } output format; any
# other value is the schema itself. The tag may be a Symbol, and `schema`
# is read under the key style `type` was written in, or under the other
# one when that key is absent ({ 'type' => 'json_schema', schema: {...} }).
def output_schema(format)
return format unless format.is_a?(Hash)

if format[:type].to_s == 'json_schema'
format.fetch(:schema) { format['schema'] }
elsif format['type'].to_s == 'json_schema'
format.fetch('schema') { format[:schema] }
else
format
end
end

def append_additional_dirs(cmd)
(@options.add_dirs || []).each { |dir| cmd.push('--add-dir', dir.to_s) }
end
Expand Down Expand Up @@ -493,15 +541,15 @@ def append_plugins(cmd)

@options.plugins.each do |plugin|
plugin_config = plugin.is_a?(SdkPluginConfig) ? plugin.to_h : plugin
plugin_type = plugin_config[:type] || plugin_config['type']
plugin_path = plugin_config[:path] || plugin_config['path']

unless %w[local plugin].include?(plugin_type)
unless %w[local plugin].include?(hash_type(plugin_config))
plugin_type = plugin_config[:type] || plugin_config['type']
raise ArgumentError, "Unsupported plugin type: #{plugin_type.inspect}"
end
next unless plugin_path

cmd.push('--plugin-dir', plugin_path)
cmd.push('--plugin-dir', path_string(plugin_path))
end
end

Expand Down
Loading
Loading