claude-agent-sdk 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +17 -8
  4. data/docs/cli-installer.md +16 -2
  5. data/docs/client.md +44 -4
  6. data/docs/errors.md +15 -1
  7. data/docs/hooks-and-permissions.md +27 -3
  8. data/docs/mcp-servers.md +36 -7
  9. data/docs/rails.md +3 -4
  10. data/docs/sessions.md +149 -34
  11. data/docs/types.md +106 -4
  12. data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
  13. data/lib/claude_agent_sdk/cli_installer.rb +68 -11
  14. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  15. data/lib/claude_agent_sdk/deprecation.rb +90 -0
  16. data/lib/claude_agent_sdk/errors.rb +8 -0
  17. data/lib/claude_agent_sdk/fiber_boundary.rb +113 -2
  18. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  19. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  20. data/lib/claude_agent_sdk/observer.rb +2 -1
  21. data/lib/claude_agent_sdk/option_warnings.rb +2 -2
  22. data/lib/claude_agent_sdk/query.rb +99 -51
  23. data/lib/claude_agent_sdk/railtie.rb +14 -3
  24. data/lib/claude_agent_sdk/sdk_mcp_server.rb +58 -38
  25. data/lib/claude_agent_sdk/session_mutations.rb +28 -16
  26. data/lib/claude_agent_sdk/session_resume.rb +39 -35
  27. data/lib/claude_agent_sdk/session_store.rb +35 -21
  28. data/lib/claude_agent_sdk/session_summary.rb +12 -5
  29. data/lib/claude_agent_sdk/sessions.rb +112 -24
  30. data/lib/claude_agent_sdk/streaming.rb +1 -1
  31. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +75 -52
  32. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +12 -5
  33. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -11
  34. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +19 -1
  35. data/lib/claude_agent_sdk/types/attributes.rb +271 -0
  36. data/lib/claude_agent_sdk/types/base.rb +320 -0
  37. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  38. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  39. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  40. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  41. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  42. data/lib/claude_agent_sdk/types/options.rb +352 -0
  43. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  44. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  45. data/lib/claude_agent_sdk/types.rb +13 -2534
  46. data/lib/claude_agent_sdk/version.rb +1 -1
  47. data/lib/claude_agent_sdk.rb +308 -73
  48. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
  49. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2976dae089e4ccc32b7974b646cb9e543c4dcdfd6bfb35eeccf6b3c4ef5e6ed
4
- data.tar.gz: 89ec51407ce6f25c962982078b3fe8301bfda9be4e57bf4a03593e9818d5913a
3
+ metadata.gz: 7d238195bb7f1725358c2ca33a8396ff1f4ab4121c7cc905e1dc2e1957a4553e
4
+ data.tar.gz: 480e5d771b68e72b0646bfa102cd921b1797d7134a925c496c5575a5e795c88e
5
5
  SHA512:
6
- metadata.gz: 4784c6381acbfedd9a921a852329dfdd64257aa60b25bf51b0f4278b5fe6ce4913b1f719941a0fece9ac818342e5129c0ffd481159bf731ee6930ecf4902b632
7
- data.tar.gz: cee7e96d3ef502138189a114716db3862d10d2ff10411e04bf0db8bc654511c547397164c7640c590eed166d11556c26b5d4e6de744f3a6ac396cdb15e40bad3
6
+ metadata.gz: 91d8f9de96e91aa5297e2342f33a57de100807978ac5c6bb7739927f99e0c41707f335c4e41512c9d843bf19afdc3d30ab97b526319e3cc45d932e5bae2cc761
7
+ data.tar.gz: e7d2504b2dcda1c0ee5d1d4174d9ece35efc1a53b9832b00595829adacc6039f423d4db4999e1139d1fb6ad93c3b532dad119f9aceb51305328d3d25d847e7f6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.37.0] - 2026-09-23
11
+
12
+ The last 0.x release before 1.0 ([roadmap](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues/126)). No runtime behaviour changes — only new warnings for things 1.0 will reject. **Run your suite on 0.37 with warnings visible before moving to 1.0:**
13
+ - A misspelled key on a value type you build (`HookMatcher`, `AgentDefinition`, `SandboxSettings`, MCP server configs, permission results, hook outputs, …) now warns once, pointing at your call; 1.0 raises `ArgumentError`.
14
+ - `msg[:name]` / camelCase readers reaching a non-attribute method (e.g. `msg[:to_h]`) warn once; in 1.0 they only reach attributes.
15
+ - The 1.0 SemVer surface is now visible: SDK internals are tagged `@api private` and hidden from the API docs, and the documented contracts (Hash-key rule, `mcp_status` / `context_usage` return Hashes, `Type#[]`) are written down.
16
+
17
+ ### Changed
18
+ - **Docs: pre-1.0 contracts written down** ([#126](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues/126)). No code changes. `Client#mcp_status` / `#get_mcp_status` and `#context_usage` / `#get_context_usage` return the CLI's raw Hash with camelCase Symbol keys. `docs/types.md` had described `McpStatusResponse` as the response type; it is the optional typed view, via `McpStatusResponse.parse(client.mcp_status)`, and there is no typed context-usage class (`docs/client.md`). A new "Hash keys" section in `docs/types.md` states one rule: Hashes passed through from the live CLI stream (`origin`, `usage`, `model_usage`, hook `tool_input`, `can_use_tool` input, SDK MCP tool args, control responses) have Symbol keys spelled as on the wire, and Hashes read from transcripts or a `SessionStore` (`SessionMessage#message`, `get_subagent_metadata`, store keys and entries) have String keys. The docs, examples and bundled skill drop their `h[:key] || h['key']` fallbacks in favour of the one correct form. `Type#[]`, `#[]=` and the camelCase readers are documented as public API, including that `#[]=` changes the object you received. `docs/sessions.md` documents `ClaudeAgentSDK.project_key_for_directory` and `ClaudeAgentSDK.fold_session_summary` for adapter authors, and `import_session_to_store`'s batching (`batch_size:` defaults to 500 entries, and a batch also ends at about 1 MiB).
19
+ - **SDK internals are now tagged `@api private` and hidden from the YARD docs** ([#126](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues/126)). The tagged namespaces are `Query`, `MessageParser`, `TranscriptMirrorBatcher`, `Sessions`, `SessionMutations`, `SessionResume`, `MaterializedResume`, `SessionSummary`, `SessionStores`, `OptionWarnings` and `FiberBoundary`, including everything nested in them. Some public classes also have internal members, and those are tagged individually: `SubprocessCLITransport`'s methods outside the `Transport` interface and its constants; `CommandBuilder`'s constants (`.new` and `#build` stay public); `CLIInstaller::Http`, `Platform`, `Release` and `Metadata` and the installer's constants other than `PINNED_CLI_VERSION`; `SdkMcpServer#handle_json`, `#handle_message` and `SdkMcpServer::ToolInputSchema`; `Client#query_handler`; and `ClaudeAgentSDK::OBSERVER_INTERFACE`. Their public faces are unchanged: `ClaudeAgentSDK.offload`, `fold_session_summary`, the session functions and `SDKSessionInfo` / `SessionMessage` remain public. Nothing changes at runtime. No method or constant was renamed, removed or made private, so every existing call keeps working. From 1.0, SemVer covers `docs/` and the YARD API without `@api private`; objects tagged `@api private` are not covered and may change in any release. `CONTRIBUTING.md` has a new "What is public API" section.
20
+ - `lib/claude_agent_sdk/types.rb` is split into one file per area under `lib/claude_agent_sdk/types/` (messages, hooks, options, ...). The code moved without edits and `types.rb` still loads every part, so `require 'claude_agent_sdk/types'` and every class and constant are unchanged; no API change ([#126](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues/126)).
21
+ - `Type`'s internal machinery is tagged `@api private` and leaves the rendered docs: `Type#dup_for_options`, `Type.deep_dup_for_options`, `Type::OptionValue`, `Type.inspect_filtered`, `Type.inspect_filtered_attributes`, and the new `Type.strict_attributes` / `.strict_attributes?` / `.declare_attributes` / `.attribute?` / `.attribute_names`. They are not part of the SemVer surface. `Type#[]`, `#[]=`, the camelCase readers, `#to_h`, `.wrap` and `.from_hash` stay public ([#126](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues/126)).
22
+
23
+ ### Deprecated
24
+ - **Unknown keys on user-constructed types.** The value types you build and pass in (option values such as `AgentDefinition`, `SandboxSettings`, the thinking configs and the MCP server configs; `HookMatcher`; hook outputs; `PermissionResultAllow` / `PermissionResultDeny`; `PermissionUpdate`; `PermissionRuleValue`) silently dropped a misspelled key, so `HookMatcher.new(matchr: 'Bash')` quietly built a matcher with no `matcher` set. `.new` and `#[]=` now warn once per class and key, at the caller's line: `ClaudeAgentSDK::HookMatcher: unknown attribute :matchr ignored; this will raise ArgumentError in 1.0 (known: hooks, matcher, timeout)`. **In 1.0 they raise `ArgumentError`**, as `ClaudeAgentOptions` already does. camelCase and String keys and a type's own discriminator (`type`, `hook_event_name`, `behavior`) are accepted, so `klass.new(value.to_h)` round-trips silently. Types parsed from CLI output (messages, content blocks, hook inputs) and every construction through `.from_hash` / `.wrap` stay lenient, so a newer CLI's extra fields never warn; permission suggestions from the CLI are now hydrated through `PermissionUpdate.wrap` for that reason. Full list in `docs/types.md` ([#126](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues/126)).
25
+ - **`Type#[]`, `#[]=` and camelCase methods reaching something other than an attribute.** They are public API for a type's attributes (its `attr_*` fields, the backward-compatible `RateLimitEvent#data`, predicates such as `options.forkSession?`, and any method your own code adds to a subclass, mixin or instance), but they resolved any public method: `msg[:to_h]` returned a Hash, `msg['freeze']` froze the message, `msg.toH` called `#to_h`. Such a call still works but warns once per class and name, e.g. `ClaudeAgentSDK::ResultMessage#[]: :to_h is not an attribute; Type#[] will only read attributes in 1.0`. **In 1.0 a name that is not an attribute behaves like an undefined one:** `#[]` returns `nil`, `#[]=` ignores it (raises on the types above), a camelCase call raises `NoMethodError`, and `respond_to?` says `false`. Names that are not methods at all keep today's behaviour and do not warn. `UserMessage#text` / `AssistantMessage#text` are convenience methods, not attributes ([#126](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues/126)).
26
+
27
+ ## [0.36.0] - 2026-09-23
28
+
29
+ The first step on the [road to 1.0](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues/126): one `session_store:` argument for every session function (the store-specific twins are deprecated), `ClaudeAgentSDK.ask`, String tool results, and the last audit follow-ups (#119–#121). **Read before upgrading:**
30
+ - An `exit`, `Interrupt` or signal raised inside a hook, `can_use_tool` or SDK MCP handler now **ends the process after the CLI gets its error response**. Since 0.34.0 a tool handler's `exit` was turned into an `isError` result and the process kept running, which also swallowed a real Ctrl-C or SIGTERM in `:inline` mode.
31
+ - The `*_from_store` / `*_via_store` session functions print a one-time deprecation warning; switch to `list_sessions(session_store: store)` etc. (table under **Deprecated**).
32
+ - Local-disk session APIs raise `ConfigDirError` (not `ArgumentError`) on hosts without a home directory; a session with no prompt has `first_prompt` `nil` on the disk path too (was `''`).
33
+
34
+ ### Added
35
+ - **SDK MCP tool handlers may return a String.** `create_tool('greet', ...) { |args| "Hello, #{args[:name]}!" }` now sends Claude a single text block, the same as returning `{ content: [{ type: 'text', text: "Hello, ..." }] }`. Both dispatch paths (`tools/call` through the MCP server and the direct `SdkMcpServer#call_tool`) accept it. Hash returns behave exactly as before and remain the form for `is_error`, `structured_content`, images and several blocks; any other non-Hash return still produces the in-band "must return a hash with :content key" error. The `create_tool` YARD examples, README and `docs/mcp-servers.md` (new "Handler Return Values" section) lead with the String form.
36
+ - **`ClaudeAgentSDK.ask(prompt, options: nil)`** — runs `query` to completion and returns the final `ResultMessage`, so `ClaudeAgentSDK.ask("What is 2 + 2?").result` is the answer text, with cost, usage and `session_id` on the same object. It is `query` underneath: same prompt types (String or Enumerable), same `options:` and `transport:`, same errors (a terminal error exit still raises `ResultError`; an `is_error` result that is not followed by an error exit is returned like any other). An optional block receives every message as it arrives, so callers can stream progress and still get the result. It consumes the whole stream and returns the last `ResultMessage`; if the stream ends without one it raises `CLIConnectionError`. The README Quick Start now leads with it.
37
+ - **Ruby-style `Client` method names next to the Python-parity ones**: `client.model = 'haiku'` (`set_model`), `client.permission_mode = 'plan'` (`set_permission_mode`), `client.context_usage` (`get_context_usage`) and `client.mcp_status` (`get_mcp_status`). Each delegates to its parity counterpart, so both spellings send the same control request, raise the same `CLIConnectionError` before `connect`, and an override of the parity method covers both. The parity names stay, so code ported from the Python docs keeps working. (`server_info` / `get_server_info` already existed as a pair.) `docs/client.md` lists both spellings.
38
+ - **`CLIInstaller.root` — a configurable root for the vendored CLI.** `CLIInstaller.default_dir` (and so `install`, `install_pinned`, `installed_path` without `dir:`, and the transport's discovery of the vendored binary) is `vendor/claude` under `CLIInstaller.root` when it is set, and under the working directory at call time when it is `nil`, the default. A process whose cwd is not the project root, such as a daemonized worker, can now find the vendored binary instead of falling through to `PATH`. The setter takes a String or Pathname, absolutizes it once, and rejects an empty path; `nil` restores the working-directory default. Unset, nothing changes, and an explicit `dir:` still wins.
39
+ - **The Railtie anchors CLI discovery to `Rails.root`**: an initializer sets `CLIInstaller.root ||= Rails.root` before `config/initializers` run, so an app initializer can override it and a root set in `config/application.rb` is kept. The `claude_agent_sdk:install_cli` task, which does not boot the app, installs under an explicitly set `CLIInstaller.root` if there is one, and under `Rails.root/vendor/claude` as before otherwise. The generated initializer's commented `cli_path:` line no longer describes a cwd workaround. `docs/cli-installer.md` gains a "Where `vendor/claude` is" section.
40
+ Groundwork for 1.0 ([#126](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues/126)): the sessions API collapses to one function per operation, and the store-specific twins are deprecated. Nothing is removed; every existing call keeps working.
41
+ - **`session_store:` on every session function.** `list_sessions`, `get_session_info`, `get_session_messages`, `list_subagents`, `get_subagent_metadata`, `get_subagent_messages`, `rename_session`, `tag_session`, `delete_session` and `fork_session` take an optional `session_store:`. Omitted or `nil`, they work on local disk exactly as before; given a store, they run the same code the `*_from_store` / `*_via_store` function did, with the same arguments. Two differences between the paths are documented in `docs/sessions.md`: `directory: nil` means every project on disk but the current working directory with a store (a store cannot enumerate projects), and `include_worktrees:` is disk-only: with `session_store:`, `list_sessions` accepts only the default `true` and raises `ArgumentError` for `false` or `nil` instead of silently ignoring the filter.
42
+ - **`ClaudeAgentSDK::ConfigDirError`** (a `ClaudeSDKError`), raised by the local-disk session APIs when the Claude config directory cannot be located: `CLAUDE_CONFIG_DIR` is unset and there is no usable home directory for the default `~/.claude`. Its message says to set `CLAUDE_CONFIG_DIR` (#120).
43
+ - CI: a macOS leg (Ruby 3.4) for the main suite; simplecov coverage (`COVERAGE=1 bundle exec rspec`) on the Linux Ruby 3.4 leg, with line/branch totals in the job summary and the HTML report as an artifact; and a weekly real-CLI integration run (`.github/workflows/integration.yml`) against `CLIInstaller::PINNED_CLI_VERSION`, also triggered by PRs that touch the installer. Dependabot keeps the workflows' actions current.
44
+ - `CONTRIBUTING.md`, `SECURITY.md`, and issue and pull request templates.
45
+
46
+ ### Changed
47
+ - **`exit` / `Interrupt` / signal exceptions from an SDK MCP tool handler terminate the process again, after the CLI gets its `isError` result.** Since #77 (0.34.0) they were converted into an `isError` result and the process kept running. That also swallowed a real Ctrl-C or `SIGTERM` landing in an `:inline` handler. The `isError` text now names the exception class (`"SystemExit: exit"` rather than `"exit"`). `SdkMcpServer#call_tool` and `#handle_message`, called directly without a session, let these exceptions propagate instead of returning an `isError` result. (#119)
48
+ - **Root-module plumbing is tagged `@api private`** and no longer appears in the generated YARD docs (`.yardopts` gains `--hide-api private`; `--no-private` alone never hid `@api private` objects): `resolve_observers`, `extract_sdk_mcp_servers`, `convert_hooks_to_internal_format`, `configure_can_use_tool`, `extract_exclude_dynamic_sections`, `extract_system_prompt_snapshot`, `notify_observers`, `check_inline_isolation`, `extract_user_prompt_text`, `prompt_text_from_content`, `observing_prompt_stream`, `flexible_fetch`, `normalize_tool_result`, and the tool-schema helpers `deep_symbolize_keys`, `deep_normalize_schema`, `prebuilt_json_schema?`, `normalize_tool_schema`, `ruby_type_to_json_schema`. They still work, but they are not part of the public API and move under `ClaudeAgentSDK::Internal` in 1.0. `fold_session_summary` stays public, since SessionStore adapters call it from `#append`. The existing `@api private` tags (`FiberBoundary` internals, `CancellationSignal#cancel`) are hidden too; `#cancel`'s tag is moved to its own line, where YARD recognizes it.
49
+ - `examples/rails_actioncable_example.rb` and `examples/rails_background_job_example.rb` use `ClaudeAgentSDK::Client.open` instead of hand-rolled `Async { connect … ensure disconnect }.wait`, matching `docs/rails.md`.
50
+ - RuboCop targets Ruby 3.2, the gemspec floor (was 3.0). The resulting autocorrections (anonymous block forwarding, dropping `require 'set'`) change no behavior.
51
+
52
+ ### Deprecated
53
+ - **The ten store-specific session functions**, removed in 1.0. Each still returns exactly what it did before (a `nil` `session_store:` still fails rather than falling back to disk), and prints a one-time warning per method per process naming its replacement and the calling line, e.g. `app/jobs/sync.rb:12: warning: ClaudeAgentSDK.list_sessions_from_store is deprecated and will be removed in 1.0; use ClaudeAgentSDK.list_sessions(session_store: store)`. The warning uses plain `Kernel#warn`, not `category: :deprecated`, because Ruby hides that category unless `Warning[:deprecated]` is enabled; `-W0` / `$VERBOSE = nil` silences it.
54
+
55
+ | Deprecated | Replacement |
56
+ |---|---|
57
+ | `list_sessions_from_store(session_store: s, ...)` | `list_sessions(session_store: s, ...)` |
58
+ | `get_session_info_from_store(session_store: s, ...)` | `get_session_info(session_store: s, ...)` |
59
+ | `get_session_messages_from_store(session_store: s, ...)` | `get_session_messages(session_store: s, ...)` |
60
+ | `list_subagents_from_store(session_store: s, ...)` | `list_subagents(session_store: s, ...)` |
61
+ | `get_subagent_metadata_from_store(session_store: s, ...)` | `get_subagent_metadata(session_store: s, ...)` |
62
+ | `get_subagent_messages_from_store(session_store: s, ...)` | `get_subagent_messages(session_store: s, ...)` |
63
+ | `rename_session_via_store(session_store: s, ...)` | `rename_session(session_store: s, ...)` |
64
+ | `tag_session_via_store(session_store: s, ...)` | `tag_session(session_store: s, ...)` |
65
+ | `delete_session_via_store(session_store: s, ...)` | `delete_session(session_store: s, ...)` |
66
+ | `fork_session_via_store(session_store: s, ...)` | `fork_session(session_store: s, ...)` |
67
+
68
+ All other arguments carry over unchanged. `import_session_to_store` is not affected.
69
+
70
+ ### Fixed
71
+ - **`exit`, `Interrupt` and signal exceptions raised while a hook, `can_use_tool`, or an SDK MCP resource reader / prompt generator runs no longer leave the CLI without an answer.** The SDK now responds first, then re-raises. The CLI gets exactly the response an ordinary exception from that callback produces, naming the exception class (`"SystemExit: exit"`, `"Interrupt"`, `"SignalException: SIGTERM"`). For hooks and `can_use_tool` that is the error control response; for `resources/read` / `prompts/get` it is a JSON-RPC `-32603` error. The transport flushes that response, and then the original exception propagates, so the process terminates as plain Ruby would: `exit 3` exits with status 3, and Ctrl-C interrupts. Previously no response was written and the reactor stopped. In the default `:thread` scheduling, Ruby also re-raised the worker thread's `SystemExit` on the main thread, which answered with a misleading `Cancelled`. This covers every dispatch site: `can_use_tool`, hooks with and without a `HookMatcher#timeout` (both variants), resources, prompts and tool handlers. It also covers a real Ctrl-C / `SIGTERM` that MRI delivers to the main thread while an `:inline` callback runs there, and a `:thread` hook that calls `exit` after its timeout expired (only `exit` is carried out of such an abandoned worker: an `Interrupt` or signal exception it raises ends that thread alone, as in plain Ruby). Cancellation (`Async::Stop`, hook timeouts) still propagates unchanged. A `callback_wrapper` sees an internal `StandardError` carrier whose `#cause` is the original, so ensure-based wrappers still clean up; the original is re-raised even if the wrapper swallows the carrier. Observers and message blocks are unchanged. (#119)
72
+ - **Session APIs on hosts without a home directory (#120).** With `CLAUDE_CONFIG_DIR` unset and `HOME` unset with no passwd entry (`docker --user` in a minimal image) or an empty/relative `HOME`:
73
+ - the local-disk session APIs (`list_sessions`, `get_session_*`, `list_subagents`, `rename_session` / `tag_session` / `delete_session` / `fork_session`, `import_session_to_store`) raise `ConfigDirError` instead of a bare `ArgumentError` from `~` expansion;
74
+ - a fresh session with a `session_store` no longer fails at connect. The transcript mirror cannot map the CLI's transcript files to store keys without a projects dir, so each unmappable batch is reported as a `MirrorErrorMessage` (with a `nil` key) and counted as dropped, while the session itself runs normally. `SessionStores.projects_dir` returns `nil` in this case instead of raising.
75
+ - **Store-backed resume seeds auth and settings from the home the CLI subprocess will use (#120).** When `options.env` sets `HOME`, `.credentials.json`, `settings.json` / `cowork_settings.json` and `.claude.json` are now read from under that home, as `CLAUDE_CONFIG_DIR` already was, instead of the parent process's home. An empty or relative `HOME` there, or `HOME => nil`, counts as no home, so those files are skipped. The transcript mirror resolves the subprocess's default `~/.claude/projects` the same way, so a `HOME` override no longer sends every mirror frame down the "not under projects dir" drop path.
76
+ - **Remaining disk/store session read inconsistencies (#121):**
77
+ - Store listings report `SDKSessionInfo#last_modified` as Integer epoch milliseconds, as documented, whatever shape the adapter's `mtime` has (ISO-8601 String, numeric String, Float, or now `Time`, e.g. an ActiveRecord `updated_at`). Previously only the ordering was coerced and the raw value leaked through. An unusable mtime (including non-finite numbers) reads as `0`, the value it already sorted by.
78
+ - `continue_conversation` with a `session_store` breaks equal-mtime ties by `session_id`, like the listings, instead of resuming whichever session the adapter listed first.
79
+ - `first_prompt` is `nil` on the disk path too (it was `''`) when a session has no prompt, matching the store path and the Python SDK.
80
+ - `cwd`: both paths take the first non-blank top-level `cwd`. The disk path took the first `cwd` even when empty (then fell back to the project path) and also matched `cwd` keys nested in tool inputs; the store fold now also skips whitespace-only values, so a sidecar no longer locks on one.
81
+ - `rename_session` / `tag_session` / `delete_session` / `fork_session` and their `_via_store` counterparts validate `session_id` (and `up_to_message_id`) at the boundary like the readers: a non-String id raises `ArgumentError` ("Invalid session_id") instead of `NoMethodError`.
82
+ - `list_sessions` deduplicates a session found in several project directories deterministically: newest `last_modified`, then the larger file, then the project directory that sorts first (the global scan now walks project directories in name order). Equal mtimes used to keep whichever copy the filesystem listed first.
83
+
10
84
  ## [0.35.0] - 2026-09-23
11
85
 
12
86
  First-class Rails integration and a first-impressions pass. **Rails users:** if your initializer uses the previously documented `->(inv) { Rails.application.executor.wrap { inv.call } }` callback wrapper, switch to `ClaudeAgentSDK::Railtie.callback_wrapper` — the bare form can deadlock in development (see **Fixed**).
data/README.md CHANGED
@@ -29,7 +29,7 @@ A Ruby SDK for the [Claude Code](https://docs.claude.com/en/docs/claude-code-ove
29
29
 
30
30
  ```ruby
31
31
  # Gemfile
32
- gem 'claude-agent-sdk', '~> 0.35.0'
32
+ gem 'claude-agent-sdk', '~> 0.37.0'
33
33
  ```
34
34
 
35
35
  Then `bundle install`, or install directly with `gem install claude-agent-sdk`. To track unreleased changes, point the Gemfile at GitHub: `gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'`.
@@ -75,14 +75,23 @@ The block runs on a plain thread, so ActiveRecord calls inside it just work. [do
75
75
  ```ruby
76
76
  require 'claude_agent_sdk'
77
77
 
78
- ClaudeAgentSDK.query(prompt: "What is 2 + 2?") do |message|
78
+ puts ClaudeAgentSDK.ask("What is 2 + 2?").result
79
+ ```
80
+
81
+ `ask` runs the whole conversation and returns the final `ResultMessage`: `#result` is the answer, and the same object carries `total_cost_usd`, `usage`, `session_id` and `structured_output` (`puts` on it prints a summary such as `[result: success, 1 turn, 2.1s, $0.0031]`). It takes the same prompt and `options:` as `query()`, and given a block it also yields every message as it arrives:
82
+
83
+ ```ruby
84
+ result = ClaudeAgentSDK.ask("Explain Ruby's GVL in three sentences") do |message|
79
85
  puts message.text if message.is_a?(ClaudeAgentSDK::AssistantMessage)
80
86
  end
87
+ puts result
81
88
  ```
82
89
 
90
+ It raises the same errors as `query()` (see [docs/errors.md](docs/errors.md)), plus `CLIConnectionError` if the stream ends without a result.
91
+
83
92
  ### `query()` — one-shot and streaming
84
93
 
85
- `query()` runs a single conversation and yields each response message to the block.
94
+ `query()` runs a single conversation and yields each response message to the block. Reach for it over `ask` when you handle the messages yourself, or want to stop early with `break`.
86
95
 
87
96
  ```ruby
88
97
  options = ClaudeAgentSDK::ClaudeAgentOptions.new(
@@ -145,7 +154,7 @@ Tools are Ruby blocks that run in-process, with no subprocess or IPC between Cla
145
154
 
146
155
  ```ruby
147
156
  greet = ClaudeAgentSDK.create_tool('greet', 'Greet a user', { name: :string }) do |args|
148
- { content: [{ type: 'text', text: "Hello, #{args[:name]}!" }] }
157
+ "Hello, #{args[:name]}!"
149
158
  end
150
159
 
151
160
  server = ClaudeAgentSDK.create_sdk_mcp_server(name: 'my-tools', tools: [greet])
@@ -156,7 +165,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
156
165
  )
157
166
  ```
158
167
 
159
- Arguments are validated against the tool's JSON Schema before your handler runs, and handler exceptions are reported back to the model in-band so it can self-correct. See [docs/mcp-servers.md](docs/mcp-servers.md) for resources, prompts, mixed SDK + external servers, and schema details.
168
+ A String return is sent to Claude as a single text block. Return a Hash instead (`{ content: [...], is_error: true }`) to flag an error, attach `structured_content:`, or send several content blocks or images. Arguments are validated against the tool's JSON Schema before your handler runs, and handler exceptions are reported back to the model in-band so it can self-correct. See [docs/mcp-servers.md](docs/mcp-servers.md) for resources, prompts, mixed SDK + external servers, and schema details.
160
169
 
161
170
  ### Hooks and permission callbacks
162
171
 
@@ -184,7 +193,7 @@ See [docs/hooks-and-permissions.md](docs/hooks-and-permissions.md) for the full
184
193
  | OpenTelemetry tracing, Langfuse, custom observers | [docs/observability.md](docs/observability.md) |
185
194
  | Rails: generator, `install_cli` task, callback wrapper, fiber safety, solid_queue fiber workers, ActionCable, jobs | [docs/rails.md](docs/rails.md) |
186
195
  | Vendoring a pinned CLI binary and CLI discovery order | [docs/cli-installer.md](docs/cli-installer.md) |
187
- | Message, content block, and configuration type reference | [docs/types.md](docs/types.md) |
196
+ | Hash-key rule, attribute access, and the message, content block, and configuration type reference | [docs/types.md](docs/types.md) |
188
197
  | Error handling, exception hierarchy, timeouts | [docs/errors.md](docs/errors.md) |
189
198
 
190
199
  API reference: [rubydoc.info/gems/claude-agent-sdk](https://rubydoc.info/gems/claude-agent-sdk). Available built-in tools: [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude).
@@ -247,11 +256,11 @@ RUN_INTEGRATION=1 bundle exec rspec # also run the real-CLI integration suite (
247
256
  BUNDLE_GEMFILE=gemfiles/rails_8.gemfile bundle exec rspec --options spec/rails/.rspec # Rails integration specs
248
257
  ```
249
258
 
250
- CI runs the suite and RuboCop on Ruby 3.2, 3.3, and 3.4, and the Rails specs against Rails 7.1 and 8. See [spec/README.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/spec/README.md) for the test layout.
259
+ CI runs the suite and RuboCop on Ruby 3.2, 3.3, and 3.4 on Linux, the suite on macOS, and the Rails specs against Rails 7.1 and 8; a weekly job runs the integration suite against the pinned CLI. See [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) for the development setup and [spec/README.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/spec/README.md) for the test layout.
251
260
 
252
261
  ## Contributing
253
262
 
254
- Bug reports and pull requests are welcome on [GitHub](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues). Please include a failing spec with bug reports where possible, and keep pull requests focused on one change. Releases follow [Semantic Versioning](https://semver.org/) and are recorded in the [CHANGELOG](CHANGELOG.md).
263
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues). Please include a failing spec with bug reports where possible, and keep pull requests focused on one change; [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) has the details. Report security vulnerabilities privately, as described in [SECURITY.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/SECURITY.md). Releases follow [Semantic Versioning](https://semver.org/) and are recorded in the [CHANGELOG](CHANGELOG.md).
255
264
 
256
265
  ## License
257
266
 
@@ -33,6 +33,20 @@ Failures (unsupported platform, invalid version, HTTP error, response-size cap,
33
33
 
34
34
  **A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. A first install that fails leaves nothing behind at all.
35
35
 
36
+ ## Where `vendor/claude` is
37
+
38
+ With no `dir:`, `install`, `install_pinned` and `installed_path` use `CLIInstaller.default_dir`: `vendor/claude` under `CLIInstaller.root`, or under the process's working directory at call time while `root` is unset (the default). Transport discovery uses the same directory, so installing and finding the binary agree.
39
+
40
+ Set `root` when a process that runs agents does not start in the project root — a daemonized worker, a job runner launched from `/`, a systemd unit without `WorkingDirectory=`. Otherwise that process looks for `vendor/claude` under its own working directory, misses the vendored binary, and falls through to whatever `claude` is on `PATH`:
41
+
42
+ ```ruby
43
+ # early in boot, before the first query
44
+ ClaudeAgentSDK::CLIInstaller.root = '/srv/myapp' # a String or a Pathname
45
+ ClaudeAgentSDK::CLIInstaller.default_dir # => "/srv/myapp/vendor/claude"
46
+ ```
47
+
48
+ A relative path is resolved against the working directory once, when you set it. `nil` restores the working-directory default. In a Rails app you don't need this line: the Railtie sets `root` to `Rails.root` during boot (see [docs/rails.md](rails.md)). An explicit `dir:` argument always wins over `root`.
49
+
36
50
  > The vendored directory is trusted input: anything that can write to it can replace the binary the SDK executes. Keep it inside your deploy artifact, owned by the deploy user and not world-writable, exactly as you would treat `bin/`.
37
51
 
38
52
  ## Docker and `bin/setup`
@@ -62,7 +76,7 @@ require 'claude_agent_sdk/tasks' # loads only CLIInstaller, not the whole SDK
62
76
 
63
77
  ```bash
64
78
  bin/rails claude_agent_sdk:install_cli # Rails: installs PINNED_CLI_VERSION into Rails.root/vendor/claude
65
- rake claude_agent_sdk:install_cli # elsewhere: into vendor/claude under the working directory
79
+ rake claude_agent_sdk:install_cli # elsewhere: into CLIInstaller.default_dir (vendor/claude under the working directory unless root is set)
66
80
  rake claude_agent_sdk:install_cli CLAUDE_CLI_VERSION=x.y.z # a version of your own, or 'stable' / 'latest'
67
81
  ```
68
82
 
@@ -83,6 +97,6 @@ The variable is deliberately not rake's conventional `VERSION`, which Rails' `db
83
97
  With no explicit `cli_path:` in `ClaudeAgentOptions`, the transport probes in this order:
84
98
 
85
99
  1. `CLAUDE_CLI_PATH` — an explicit path to an executable, no discovery at all (a relative value is resolved against the process's working directory, not `cwd:`)
86
- 2. The vendored binary (`CLIInstaller.installed_path`) — deliberately ahead of `PATH`, so a pinned install beats whatever is installed globally
100
+ 2. The vendored binary (`CLIInstaller.installed_path`, i.e. `vendor/claude` under `CLIInstaller.root` or the working directory — see [above](#where-vendorclaude-is)) — deliberately ahead of `PATH`, so a pinned install beats whatever is installed globally
87
101
  3. `which claude`
88
102
  4. Common install locations (`~/.claude/local/claude`, `/usr/local/bin/claude`, …) — only an executable regular file counts, and the `~` ones are skipped when there is no usable home directory (HOME unset with no passwd entry, or a non-absolute HOME)
data/docs/client.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  `ClaudeAgentSDK::Client` supports bidirectional, interactive conversations with Claude Code. Unlike `query()`, `Client` enables **custom tools**, **hooks**, and **permission callbacks**, all of which can be defined as Ruby procs/lambdas. The Client class automatically uses streaming mode for bidirectional communication, allowing you to send multiple queries dynamically during a single session without closing the connection.
4
4
 
5
+ For a single question you don't need a session: `ClaudeAgentSDK.ask(prompt, options:)` runs `query()` to completion and returns the final `ResultMessage` (`#result` is the answer text), optionally yielding each message to a block on the way. See the README's [Quick Start](../README.md#quick-start).
6
+
5
7
  ## Basic Usage
6
8
 
7
9
  `Client.open` connects, yields the client, and always disconnects when the block exits (exceptions propagate after the disconnect). It returns the block's value, and creates an `async` reactor if it isn't already running inside one.
@@ -46,9 +48,10 @@ Async do
46
48
  client.connect
47
49
 
48
50
  client.interrupt # Send interrupt signal
49
- client.set_permission_mode('acceptEdits') # Change permission mode mid-conversation
50
- client.set_model('claude-sonnet-5') # Switch model mid-conversation
51
- status = client.get_mcp_status # Inspect MCP server status
51
+ client.permission_mode = 'acceptEdits' # Change permission mode mid-conversation
52
+ client.model = 'claude-sonnet-5' # Switch model mid-conversation (nil = default)
53
+ usage = client.context_usage # Context window usage by category
54
+ status = client.mcp_status # Inspect MCP server status
52
55
  info = client.get_server_info # Inspect server init info
53
56
  client.reconnect_mcp_server('my-server') # Reconnect a failed MCP server
54
57
  client.toggle_mcp_server('my-server', false) # Enable/disable an MCP server
@@ -62,6 +65,43 @@ Async do
62
65
  end.wait
63
66
  ```
64
67
 
68
+ The Ruby-style names above sit next to the Python SDK's spellings, and both work, so code ported from the Python docs runs unchanged:
69
+
70
+ | Ruby style | Python-parity name |
71
+ |------------|--------------------|
72
+ | `client.model = 'haiku'` | `client.set_model('haiku')` |
73
+ | `client.permission_mode = 'plan'` | `client.set_permission_mode('plan')` |
74
+ | `client.context_usage` | `client.get_context_usage` |
75
+ | `client.mcp_status` | `client.get_mcp_status` |
76
+ | `client.server_info` | `client.get_server_info` |
77
+
78
+ Each Ruby-style method calls its parity counterpart, so both send the same control request and raise `CLIConnectionError` when the client is not connected. The one exception is `server_info`, which reads the cached initialization result and returns `nil` instead of raising before `connect`. As with any Ruby setter, `client.model = 'haiku'` evaluates to `'haiku'`, not to the control response.
79
+
80
+ ### MCP status and context usage return Hashes
81
+
82
+ `mcp_status` / `get_mcp_status` and `context_usage` / `get_context_usage` return the CLI's control response payload as a plain Hash, unchanged: Symbol keys spelled as on the wire, which for these payloads is camelCase (`:mcpServers`, `:serverInfo`, `:totalTokens`), at every level of nesting (see [Hash keys](types.md#hash-keys)). The SDK does not model or filter the payload, so fields added by newer CLI versions come through. A response without a payload reads as `{}`.
83
+
84
+ ```ruby
85
+ status = client.mcp_status
86
+ status[:mcpServers].each { |s| puts "#{s[:name]}: #{s[:status]}" }
87
+ status.dig(:mcpServers, 0, :serverInfo, :version)
88
+
89
+ usage = client.context_usage
90
+ puts "#{usage[:totalTokens]} / #{usage[:maxTokens]} tokens"
91
+ ```
92
+
93
+ For a typed view of the MCP status, parse the Hash yourself:
94
+
95
+ ```ruby
96
+ typed = ClaudeAgentSDK::McpStatusResponse.parse(client.mcp_status)
97
+ typed.mcp_servers.each do |server| # McpServerStatus
98
+ puts "#{server.name} #{server.status} #{server.server_info&.version}"
99
+ server.tools&.each { |tool| puts " #{tool.name} read_only=#{tool.annotations&.read_only}" }
100
+ end
101
+ ```
102
+
103
+ `McpServerStatus#config` is an `McpSdkServerConfigStatus` or `McpClaudeAIProxyServerConfig` for `sdk` and `claudeai-proxy` servers, and the raw Hash for every other server type. There is no typed class for context usage; read the Hash.
104
+
65
105
  ## Custom Transport
66
106
 
67
107
  By default, `Client` uses `SubprocessCLITransport` to spawn the Claude Code CLI locally. You can provide a custom transport class to connect via other channels (e.g., remote SSH, WebSocket, or a sandbox VM).
@@ -72,7 +112,7 @@ A transport must implement six methods:
72
112
  |---|---|
73
113
  | `connect` | Establish the connection / spawn the remote CLI |
74
114
  | `write(data)` | Send raw JSON-line bytes to stdin |
75
- | `read_messages { \|hash\| ... }` | Yield parsed JSON messages from stdout; block until the stream closes |
115
+ | `read_messages { \|hash\| ... }` | Yield each stdout line as a Hash parsed with `JSON.parse(line, symbolize_names: true)` (the SDK reads Symbol keys; see [Hash keys](types.md#hash-keys)); block until the stream closes |
76
116
  | `end_input` | Signal EOF on stdin |
77
117
  | `close` | Terminate and clean up |
78
118
  | `ready?` | Report whether the transport can accept I/O |
data/docs/errors.md CHANGED
@@ -44,6 +44,15 @@ rescue ClaudeAgentSDK::CLIJSONDecodeError => e
44
44
  end
45
45
  ```
46
46
 
47
+ An ordinary exception raised inside your own callbacks (hooks, `can_use_tool`,
48
+ SDK MCP tool handlers, resource readers and prompt generators) is not raised
49
+ to the code above. It is reported to the CLI as a failed callback, and the
50
+ session keeps running. `exit`, `Interrupt` and other signal exceptions are the
51
+ exception: the CLI gets the error response first, and then they propagate as
52
+ Ruby normally would. See
53
+ [Hooks & Permission Callbacks](hooks-and-permissions.md#when-a-callback-raises)
54
+ and [MCP Servers](mcp-servers.md).
55
+
47
56
  ## Terminal Error Results
48
57
 
49
58
  When a run fails, the CLI emits a `result` message with `is_error: true` (which
@@ -104,6 +113,10 @@ class CLINotFoundError < CLIConnectionError
104
113
  # @param cli_path [String, nil] Optional path to the CLI that was not found
105
114
  end
106
115
 
116
+ # Raised by the local-disk session APIs when CLAUDE_CONFIG_DIR is unset and
117
+ # no usable home directory exists for the default ~/.claude
118
+ class ConfigDirError < ClaudeSDKError; end
119
+
107
120
  # Raised when the Claude Code process fails
108
121
  class ProcessError < ClaudeSDKError
109
122
  attr_reader :exit_code, # Integer | nil
@@ -138,9 +151,10 @@ end
138
151
  | Error | Description |
139
152
  |-------|-------------|
140
153
  | `ClaudeSDKError` | Base error for all SDK errors |
141
- | `CLIConnectionError` | Connection issues — including every write after a stdin write was cancelled mid-frame (the connection is unusable from then on — reconnect) |
154
+ | `CLIConnectionError` | Connection issues — including every write after a stdin write was cancelled mid-frame (the connection is unusable from then on — reconnect), and `ClaudeAgentSDK.ask` when the stream ends without a `ResultMessage` |
142
155
  | `ControlRequestTimeoutError` | Control protocol timeout (configurable via env var) |
143
156
  | `CLINotFoundError` | Claude Code not installed |
157
+ | `ConfigDirError` | A local-disk session API (`list_sessions`, `get_session_*`, `rename_session`, ...) could not locate the Claude config directory: `CLAUDE_CONFIG_DIR` is unset and there is no usable home directory (`HOME` unset with no passwd entry, as under `docker --user` in a minimal image, or an empty/relative `HOME`). Set `CLAUDE_CONFIG_DIR` |
144
158
  | `ProcessError` | Process failed (includes `exit_code` and `stderr`) — also raised when the CLI is still running 5s after closing stdout and the SDK had to terminate it |
145
159
  | `ResultError` | Run ended on a terminal error result (subclasses `ProcessError`; adds `subtype`, `errors`, `api_error_status`, `terminal_reason`, ...) — rescue it first |
146
160
  | `CLIJSONDecodeError` | JSON parsing issues — including stdout ending mid-frame (a truncated final message; `line` holds the partial frame) |
@@ -19,7 +19,9 @@ All hook input objects include common fields like `session_id`, `transcript_path
19
19
  - `SubagentStart` → `SubagentStartHookInput` (`agent_id`, `agent_type`)
20
20
  - `PermissionRequest` → `PermissionRequestHookInput` (`tool_name`, `tool_input`, `permission_suggestions`)
21
21
 
22
- All 27 hook events have typed input classes. See [`ClaudeAgentSDK::HOOK_EVENTS`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/types.rb) and [examples/lifecycle_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb).
22
+ `tool_input` (and the `input` a [permission callback](#permission-callbacks) receives) is the CLI's Hash passed through unchanged, so its keys are Symbols spelled as on the wire: `tool_input[:command]`, `input[:file_path]`. See [Hash keys](types.md#hash-keys).
23
+
24
+ All 27 hook events have typed input classes. See [`ClaudeAgentSDK::HOOK_EVENTS`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/types/hooks.rb) and [examples/lifecycle_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb).
23
25
 
24
26
  `background_tasks` and `session_crons` are optional arrays of raw CLI hashes.
25
27
  `nil` means the CLI did not provide a snapshot; `[]` means it provided an empty
@@ -40,7 +42,7 @@ Async do
40
42
  return {} unless input.respond_to?(:tool_name) && input.tool_name == 'Bash'
41
43
 
42
44
  tool_input = input.tool_input || {}
43
- command = tool_input[:command] || tool_input['command'] || ''
45
+ command = tool_input[:command] || ''
44
46
  block_patterns = ['rm -rf', 'foo.sh']
45
47
 
46
48
  block_patterns.each do |pattern|
@@ -105,7 +107,7 @@ Async do
105
107
  return ClaudeAgentSDK::PermissionResultAllow.new if tool_name == 'Read'
106
108
 
107
109
  if tool_name == 'Write'
108
- file_path = input[:file_path] || input['file_path']
110
+ file_path = input[:file_path]
109
111
  if file_path && file_path.include?('/etc/')
110
112
  return ClaudeAgentSDK::PermissionResultDeny.new(
111
113
  message: 'Cannot write to sensitive system files',
@@ -193,3 +195,25 @@ never raises — shadowing can be intentional, e.g. a callback used solely for
193
195
  tools outside `allowed_tools`. To gate every tool call including
194
196
  auto-approved ones, use a `PreToolUse` hook instead (note that a `PreToolUse`
195
197
  hook returning an allow decision also skips this callback).
198
+
199
+ ## When a callback raises
200
+
201
+ An exception raised inside a hook or a `can_use_tool` callback fails that
202
+ control request: the CLI receives an error response carrying the exception
203
+ message, and the session carries on with later requests. The request's
204
+ cancellation signal is invalidated, as for any other callback failure.
205
+
206
+ `exit`, `Interrupt` and other signal exceptions are never swallowed. If one
207
+ is raised while a callback runs (by the callback itself, or a real Ctrl-C /
208
+ `SIGTERM` arriving while an `:inline` callback runs on the main thread), the
209
+ CLI first gets the same error response, naming the exception class
210
+ (`"SystemExit: exit"`, `"Interrupt"`), and then the exception propagates as
211
+ Ruby normally would: `exit 3` ends the process with status 3, and Ctrl-C
212
+ interrupts it. This holds in both `:thread` and `:inline` scheduling, and also
213
+ when a `:thread` hook calls `exit` after its `HookMatcher#timeout` has already
214
+ expired. A `callback_wrapper` sees the exception wrapped in an internal
215
+ `StandardError` whose `#cause` is the original, so ensure-based wrappers (such
216
+ as `Rails.application.executor.wrap`) still clean up. The original is raised
217
+ again after the wrapper returns, even if the wrapper swallows the error.
218
+ Cancellation (`control_cancel_request`, `HookMatcher#timeout`, disconnect)
219
+ still propagates as before.
data/docs/mcp-servers.md CHANGED
@@ -14,7 +14,7 @@ greet_tool = ClaudeAgentSDK.create_tool(
14
14
  'greet', 'Greet a user', { name: :string },
15
15
  annotations: { title: 'Greeter', readOnlyHint: true }
16
16
  ) do |args|
17
- { content: [{ type: 'text', text: "Hello, #{args[:name]}!" }] }
17
+ "Hello, #{args[:name]}!"
18
18
  end
19
19
 
20
20
  server = ClaudeAgentSDK.create_sdk_mcp_server(
@@ -37,6 +37,27 @@ Async do
37
37
  end.wait
38
38
  ```
39
39
 
40
+ ## Handler Return Values
41
+
42
+ A handler returns either a String or a Hash:
43
+
44
+ - **A String** is sent to Claude as a single text block. `"Hello, Alice!"` is shorthand for `{ content: [{ type: 'text', text: "Hello, Alice!" }] }`.
45
+ - **A Hash** gives full control over the MCP result. `:content` (required) is an Array of MCP content blocks, so a tool can return several text blocks, images (`{ type: 'image', data: base64, mimeType: 'image/png' }`) and so on. Set `is_error: true` to tell Claude the call failed, and `structured_content:` to attach machine-readable output. Keys may be Symbols or Strings, and camelCase `isError` / `structuredContent` work too.
46
+
47
+ ```ruby
48
+ ClaudeAgentSDK.create_tool('lookup_order', 'Look up an order', { id: :string }) do |args|
49
+ order = Order.find_by(number: args[:id])
50
+ next { content: [{ type: 'text', text: "No order #{args[:id]}" }], is_error: true } unless order
51
+
52
+ {
53
+ content: [{ type: 'text', text: "Order #{order.number}: #{order.status}" }],
54
+ structured_content: { number: order.number, status: order.status }
55
+ }
56
+ end
57
+ ```
58
+
59
+ Any other return value (`nil`, an Integer, an Array, ...) is reported to Claude as an `isError: true` result saying the tool must return a hash with a `:content` key.
60
+
40
61
  ## Pre-built JSON Schemas
41
62
 
42
63
  If your schemas come from another library (e.g., [RubyLLM](https://github.com/crmne/ruby_llm)) that deep-stringifies keys, the SDK handles them transparently — both symbol-keyed and string-keyed schemas are accepted and normalized:
@@ -80,15 +101,14 @@ ClaudeAgentSDK.create_tool('save', 'Save a fact', {
80
101
 
81
102
  ```ruby
82
103
  add_tool = ClaudeAgentSDK.create_tool('add', 'Add two numbers', { a: :number, b: :number }) do |args|
83
- result = args[:a] + args[:b]
84
- { content: [{ type: 'text', text: "#{args[:a]} + #{args[:b]} = #{result}" }] }
104
+ "#{args[:a]} + #{args[:b]} = #{args[:a] + args[:b]}"
85
105
  end
86
106
 
87
107
  divide_tool = ClaudeAgentSDK.create_tool('divide', 'Divide numbers', { a: :number, b: :number }) do |args|
88
108
  if args[:b] == 0
89
109
  { content: [{ type: 'text', text: 'Error: Division by zero' }], is_error: true }
90
110
  else
91
- { content: [{ type: 'text', text: "Result: #{args[:a] / args[:b]}" }] }
111
+ "Result: #{args[:a] / args[:b]}"
92
112
  end
93
113
  end
94
114
 
@@ -105,9 +125,12 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
105
125
 
106
126
  An exception raised inside a handler is returned to the model as an
107
127
  `isError: true` result carrying the exception message, so it can self-correct.
108
- This includes `exit`, `Interrupt` and other signal exceptions: they are reported
109
- the same way instead of escaping the session and leaving the CLI waiting on the
110
- tool call. Cancellation of the tool call itself still propagates.
128
+ `exit`, `Interrupt` and other signal exceptions are never swallowed: the CLI
129
+ first gets an `isError` result naming the exception class
130
+ (`"SystemExit: exit"`), so it is not left waiting on the tool call, and then
131
+ the exception propagates as Ruby normally would (`exit` ends the process,
132
+ Ctrl-C interrupts it). Called directly, without a session,
133
+ `SdkMcpServer#call_tool` simply lets such exceptions propagate. Cancellation of the tool call itself still propagates.
111
134
 
112
135
  ## Mixed Server Support
113
136
 
@@ -172,4 +195,10 @@ server = ClaudeAgentSDK.create_sdk_mcp_server(
172
195
  )
173
196
  ```
174
197
 
198
+ An exception raised inside a resource reader or prompt generator is answered
199
+ with a JSON-RPC internal error (`-32603`) carrying the exception message. For
200
+ `exit`, `Interrupt` and other signal exceptions the CLI gets that error first,
201
+ naming the exception class, and then the exception propagates as Ruby normally
202
+ would.
203
+
175
204
  See [examples/mcp_calculator.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_calculator.rb) and [examples/mcp_resources_prompts_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_resources_prompts_example.rb) for complete examples.
data/docs/rails.md CHANGED
@@ -25,7 +25,7 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the
25
25
  bin/rails claude_agent_sdk:install_cli CLAUDE_CLI_VERSION=x.y.z # or a version of your own ('stable' / 'latest' float)
26
26
  ```
27
27
 
28
- The binary lands in `Rails.root/vendor/claude`, where the SDK finds it ahead of any `claude` on `PATH` whenever the process runs from the app root, as `bin/rails`, Puma and most job runners do (otherwise set `cli_path:`; the initializer has it commented). The task does not boot the app (no database or credentials needed), so the same line works as a cached Docker build step: `RUN bin/rails claude_agent_sdk:install_cli`. Installs are checksum-verified and idempotent — see [docs/cli-installer.md](cli-installer.md). The CLI authenticates from the environment, e.g. `ANTHROPIC_API_KEY`.
28
+ The binary lands in `Rails.root/vendor/claude`, where the SDK finds it ahead of any `claude` on `PATH`. That holds whatever the process's working directory is (a daemonized worker, a job runner started elsewhere): the Railtie points `ClaudeAgentSDK::CLIInstaller.root` at `Rails.root` during boot, before `config/initializers` run, so an initializer can still set a different root, and a root already set in `config/application.rb` is kept. The task does not boot the app (no database or credentials needed), so the same line works as a cached Docker build step: `RUN bin/rails claude_agent_sdk:install_cli`. For the same reason the task never sees a root set in `config/initializers`; if you move the CLI elsewhere, set `CLIInstaller.root` in `config/application.rb`, which both the task and discovery honour. Installs are checksum-verified and idempotent — see [docs/cli-installer.md](cli-installer.md). The CLI authenticates from the environment, e.g. `ANTHROPIC_API_KEY`.
29
29
 
30
30
  4. Run an agent from a job:
31
31
 
@@ -53,8 +53,7 @@ You do **not** need to think about this. By default (`callback_scheduling: :thre
53
53
 
54
54
  ```ruby
55
55
  tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args|
56
- user = User.find(args[:id]) # just works
57
- { content: [{ type: 'text', text: user.name }] }
56
+ User.find(args[:id]).name # just works
58
57
  end
59
58
 
60
59
  ClaudeAgentSDK.query(prompt: '...') do |message|
@@ -127,7 +126,7 @@ The one real risk: **scheduler-opaque blocking stalls the whole reactor.** CPU-b
127
126
  ```ruby
128
127
  tool = ClaudeAgentSDK.create_tool('lookup', 'Query legacy DB', { id: String }) do |args|
129
128
  row = ClaudeAgentSDK.offload { legacy_client.fetch(args[:id]) } # plain thread
130
- { content: [{ type: 'text', text: row.to_json }] }
129
+ row.to_json
131
130
  end
132
131
  ```
133
132