claude-agent-sdk 0.34.0 → 0.36.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +68 -24
  4. data/docs/cli-installer.md +38 -1
  5. data/docs/client.md +44 -20
  6. data/docs/errors.md +15 -1
  7. data/docs/hooks-and-permissions.md +22 -0
  8. data/docs/mcp-servers.md +37 -7
  9. data/docs/rails.md +92 -54
  10. data/docs/sessions.md +69 -33
  11. data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
  12. data/lib/claude_agent_sdk/cli_installer.rb +38 -8
  13. data/lib/claude_agent_sdk/deprecation.rb +51 -0
  14. data/lib/claude_agent_sdk/errors.rb +8 -0
  15. data/lib/claude_agent_sdk/fiber_boundary.rb +111 -2
  16. data/lib/claude_agent_sdk/option_warnings.rb +0 -2
  17. data/lib/claude_agent_sdk/query.rb +49 -8
  18. data/lib/claude_agent_sdk/railtie.rb +105 -0
  19. data/lib/claude_agent_sdk/sdk_mcp_server.rb +36 -25
  20. data/lib/claude_agent_sdk/session_mutations.rb +10 -10
  21. data/lib/claude_agent_sdk/session_resume.rb +19 -24
  22. data/lib/claude_agent_sdk/session_store.rb +28 -18
  23. data/lib/claude_agent_sdk/session_summary.rb +8 -3
  24. data/lib/claude_agent_sdk/sessions.rb +104 -18
  25. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +4 -13
  26. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +37 -0
  27. data/lib/claude_agent_sdk/tasks.rb +13 -0
  28. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +1 -1
  29. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +17 -1
  30. data/lib/claude_agent_sdk/types.rb +219 -3
  31. data/lib/claude_agent_sdk/version.rb +1 -1
  32. data/lib/claude_agent_sdk.rb +261 -56
  33. data/lib/generators/claude_agent_sdk/install/install_generator.rb +63 -0
  34. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +35 -0
  35. metadata +17 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff96202bf91fc547d93ae77a7b022fb83a0651574fcb1aae5e521531e33ea9e7
4
- data.tar.gz: 6956e7943d0856d9ac5975a7ba01e779159df1193301199dc0f082bf80835d88
3
+ metadata.gz: aed11255947a3321978d4d9e65cf02ca0d88a6f8b2c9e61afede1de95c247b76
4
+ data.tar.gz: e5d54c2745b1264198a25569599c4f34ac6817778914f450d7aaca8ddb7e99d5
5
5
  SHA512:
6
- metadata.gz: 9e64d2bf6a83ae79b2f77007baad0d9ff340e4ff0b264d1acc066660860e57e329af84406bc041d39af73c1030afebf96d9f2a7894038354fdf6ce9847447e6c
7
- data.tar.gz: 8e0e4e08914e30d812094a7a7cbfe4c0f128f7f83b3856a5947149b84566fd9ec72c8c9c3477778e3c196aba6694fb3524e84b3d4d1c1a919f1683aa545a271a
6
+ metadata.gz: b5c4ce0e5b2a8594eafd236fcdd4d8592cf0f900b8eede62e7e680b50d3560ea99d04180e3b42550e863a95225e9cac191fe50e8baa7c376413832b051e1900b
7
+ data.tar.gz: b45fb3972949da75f83f845244e72b74fefcc88acf57c04ed89cbdde896a7e50eda69853a3430db88163259afe88862da0c598083745a3b9778f418ec001bf2d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.36.0] - 2026-09-23
11
+
12
+ 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:**
13
+ - 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.
14
+ - The `*_from_store` / `*_via_store` session functions print a one-time deprecation warning; switch to `list_sessions(session_store: store)` etc. (table under **Deprecated**).
15
+ - 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 `''`).
16
+
17
+ ### Added
18
+ - **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.
19
+ - **`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.
20
+ - **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.
21
+ - **`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.
22
+ - **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.
23
+ 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.
24
+ - **`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.
25
+ - **`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).
26
+ - 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.
27
+ - `CONTRIBUTING.md`, `SECURITY.md`, and issue and pull request templates.
28
+
29
+ ### Changed
30
+ - **`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)
31
+ - **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.
32
+ - `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`.
33
+ - RuboCop targets Ruby 3.2, the gemspec floor (was 3.0). The resulting autocorrections (anonymous block forwarding, dropping `require 'set'`) change no behavior.
34
+
35
+ ### Deprecated
36
+ - **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.
37
+
38
+ | Deprecated | Replacement |
39
+ |---|---|
40
+ | `list_sessions_from_store(session_store: s, ...)` | `list_sessions(session_store: s, ...)` |
41
+ | `get_session_info_from_store(session_store: s, ...)` | `get_session_info(session_store: s, ...)` |
42
+ | `get_session_messages_from_store(session_store: s, ...)` | `get_session_messages(session_store: s, ...)` |
43
+ | `list_subagents_from_store(session_store: s, ...)` | `list_subagents(session_store: s, ...)` |
44
+ | `get_subagent_metadata_from_store(session_store: s, ...)` | `get_subagent_metadata(session_store: s, ...)` |
45
+ | `get_subagent_messages_from_store(session_store: s, ...)` | `get_subagent_messages(session_store: s, ...)` |
46
+ | `rename_session_via_store(session_store: s, ...)` | `rename_session(session_store: s, ...)` |
47
+ | `tag_session_via_store(session_store: s, ...)` | `tag_session(session_store: s, ...)` |
48
+ | `delete_session_via_store(session_store: s, ...)` | `delete_session(session_store: s, ...)` |
49
+ | `fork_session_via_store(session_store: s, ...)` | `fork_session(session_store: s, ...)` |
50
+
51
+ All other arguments carry over unchanged. `import_session_to_store` is not affected.
52
+
53
+ ### Fixed
54
+ - **`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)
55
+ - **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`:
56
+ - 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;
57
+ - 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.
58
+ - **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.
59
+ - **Remaining disk/store session read inconsistencies (#121):**
60
+ - 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.
61
+ - `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.
62
+ - `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.
63
+ - `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.
64
+ - `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`.
65
+ - `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.
66
+
67
+ ## [0.35.0] - 2026-09-23
68
+
69
+ 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**).
70
+
71
+ ### Added
72
+ - **Rails integration: `ClaudeAgentSDK::Railtie`**, loaded only when Rails is (`require_relative 'claude_agent_sdk/railtie' if defined?(Rails::Railtie)`, which Bundler.require satisfies in a Rails app); non-Rails processes load nothing new. It contributes a rake task and installs nothing into callback dispatch.
73
+ - **`bin/rails generate claude_agent_sdk:install`** — writes `config/initializers/claude_agent_sdk.rb` (commented `model` / `permission_mode` / `cli_path` / OpenTelemetry defaults, `callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper` enabled), appends `/vendor/claude/` to `.gitignore` once (any existing spelling counts), and prints the next steps.
74
+ - **`claude_agent_sdk:install_cli` rake task** — `CLIInstaller.install_pinned` into `Rails.root/vendor/claude`, or `install(version:)` with `CLAUDE_CLI_VERSION=x.y.z|stable|latest`; prints the installed path. It does not boot the app, so it runs in a Docker build step. Outside Rails, `require 'claude_agent_sdk/tasks'` in a Rakefile provides the same task (loading only `CLIInstaller`), installing under the working directory.
75
+ - **`ClaudeAgentSDK::Railtie.callback_wrapper`** — a `callback_wrapper` that runs SDK callbacks in `Rails.application.executor` (so ActiveRecord connections check back in), except where that deadlocks: with code reloading enabled or `config.allow_concurrency = false` it calls the callback outside the executor and releases the thread's ActiveRecord connections itself; when the executor is already active on the callback's context (`:inline` scheduling) it calls straight through. Supports Rails 7.1+.
76
+ - CI: a `rails` job runs the Rails integration specs (`spec/rails`, in their own process via `rspec --options spec/rails/.rspec`) against Rails 7.1 on Ruby 3.2 and the latest Rails 8 on Ruby 3.4 (`gemfiles/rails_7_1.gemfile`, `gemfiles/rails_8.gemfile`). The default `bundle exec rspec` run excludes `spec/rails`.
77
+ - **Every SDK type now prints its fields.** `Type#inspect` lists the non-nil attributes (`#<ClaudeAgentSDK::ResultMessage subtype="success" num_turns=3 total_cost_usd=0.012 ...>`) instead of a bare object address, and `#to_s` falls back to it, so the README's `puts message` is readable for every message type. The output is bounded for logging: Strings past 80 characters are truncated with a count of what was cut, Arrays and Hashes show their first five entries plus a count of the rest, nesting past two levels (and any reference cycle) collapses to a placeholder, and objects that only have `Kernel#inspect` (SDK MCP server instances, store adapters, observers) show as `#<ClassName>` rather than dumping their state. Callbacks (`can_use_tool`, hooks, `callback_wrapper`, ...) render from their source location, e.g. `#<Proc(lambda) permissions.rb:17>`, never through their own `#inspect`, so a raising or oversized override cannot break or flood a log line.
78
+ - **One-line `to_s` for results and system messages.** `ResultMessage#to_s` prints `[result: success, 3 turns, 4.2s, $0.0120]` (missing fields omitted; an error result appends its `errors`), `SystemMessage#to_s` prints `[system: init]`, and `TextBlock#to_s` returns its text. `UserMessage` / `AssistantMessage` keep printing their text.
79
+
80
+ ### Changed
81
+ - `docs/rails.md` opens with a getting-started path (gem → generator → `install_cli` → first job), and its ActionCable, session-resumption and background-job examples use `ClaudeAgentSDK::Client.open` instead of hand-rolled `Async { connect … ensure disconnect }.wait`. README and gemspec description lead with the Rails integration.
82
+ - **`#inspect` filters credential-bearing attributes** to `"[FILTERED]"` (Hash keys stay visible): `ClaudeAgentOptions#env` (usually carries `ANTHROPIC_API_KEY`), `McpStdioServerConfig#env`, and `McpHttpServerConfig` / `McpSSEServerConfig#headers`, since these objects end up in logs. The objects are not modified. Type subclasses declare such attributes with `inspect_filtered :name`. Typed `SystemMessage` subclasses (`InitMessage`, ...) leave the raw `@data` frame out of `#inspect`, since it repeats their attributes; a bare `SystemMessage` keeps it. Nothing sent to the CLI changes: wire output still goes through `#to_h`.
83
+ - The README's `Client` section and the basic example in `docs/client.md` now lead with `Client.open`, which creates the reactor and always disconnects, instead of the `Async do … begin … ensure client.disconnect end.wait` boilerplate. The manual `connect` / `disconnect` form is still shown for code already running inside an `Async` reactor. No API changes.
84
+ - The bundled `claude-agent-ruby` skill recommends `Client.open` and documents the install generator, the `install_cli` task and `Railtie.callback_wrapper`.
85
+ - **Gem metadata names its maintainer** (`authors: ["ya-luotao"]`, with a contact email) instead of "Community Contributors". The stale `IMPLEMENTATION.md` is removed, and the past audit reports move from the repository root to `docs/history/`, which is not packaged with the gem.
86
+
87
+ ### Fixed
88
+ - **The Rails `callback_wrapper` previously recommended in docs/rails.md, `->(inv) { Rails.application.executor.wrap { inv.call } }`, can deadlock in development.** With code reloading enabled, the request or job calling the SDK holds a share of the reload interlock while it waits for a callback running on its own thread (the default `:thread` scheduling); if a reload is requested meanwhile — e.g. after the agent edits an app file — the reloader queues for the exclusive lock and the callback's `executor.wrap` queues behind it, forever. With `config.allow_concurrency = false` the same wrapper blocks on the executor's monitor every time. The guide (and the `callback_wrapper` API docs and skill reference) now recommend `ClaudeAgentSDK::Railtie.callback_wrapper`; replace the bare lambda with it in existing initializers.
89
+
10
90
  ## [0.34.0] - 2026-09-23
11
91
 
12
92
  The September 2026 audit campaign: 17 fixes from the final audit pass plus the 28 AUDIT-2026-09-22 issues (#66–#93). A few fixes tighten behaviour that was silently wrong — read **Changed** before upgrading.
data/README.md CHANGED
@@ -8,26 +8,28 @@
8
8
  [![Docs](https://img.shields.io/badge/docs-rubydoc.info-blue)](https://rubydoc.info/gems/claude-agent-sdk)
9
9
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
10
10
 
11
- A Ruby SDK for the [Claude Code](https://docs.claude.com/en/docs/claude-code-overview) agent runtime. Build AI agents, automate coding workflows, and integrate Claude into Rails and other Ruby applications with the same capabilities as the official [TypeScript](https://github.com/anthropics/claude-agent-sdk-typescript) and [Python](https://github.com/anthropics/claude-agent-sdk-python) SDKs.
11
+ A Ruby SDK for the [Claude Code](https://docs.claude.com/en/docs/claude-code-overview) agent runtime, built for running agents in production Ruby and Rails apps. It has the same capabilities as the official [TypeScript](https://github.com/anthropics/claude-agent-sdk-typescript) and [Python](https://github.com/anthropics/claude-agent-sdk-python) SDKs, plus what a Rails deploy needs around them: a generator and CLI-vendoring rake task, callbacks that are safe to touch ActiveRecord from, a pinned CLI binary, built-in OpenTelemetry tracing, and transcript mirroring to your own storage.
12
12
 
13
13
  > **Unofficial and community-maintained.** This project is not affiliated with or supported by Anthropic. It tracks the official SDKs release by release; see the [CHANGELOG](CHANGELOG.md) for the currently synced version.
14
14
 
15
15
  ## Highlights
16
16
 
17
+ - **Rails integration.** `bin/rails generate claude_agent_sdk:install` writes the initializer and `bin/rails claude_agent_sdk:install_cli` vendors the CLI; [docs/rails.md](docs/rails.md) covers jobs, ActionCable streaming, session resumption, and solid_queue fiber workers (`callback_scheduling: :inline`).
18
+ - **Callbacks that are safe around ActiveRecord.** Tool handlers, hooks, permission callbacks, and message blocks run on a plain thread by default, outside the SDK's fiber scheduler, so thread-keyed libraries (ActiveRecord, `pg`, per-thread caches) behave as they do everywhere else in your app. `ClaudeAgentSDK::Railtie.callback_wrapper` runs them in the Rails executor so connections go back to the pool, without deadlocking development code reloading.
19
+ - **Hermetic deploys.** `CLIInstaller` vendors a checksum-verified CLI binary, pinned to the version each gem release is tested with, so production never depends on a global `npm install`.
20
+ - **Built-in OpenTelemetry observer** with Langfuse support; no third-party instrumentation library required.
21
+ - **Transcript mirroring.** A `SessionStore` adapter mirrors session transcripts to your own storage (reference adapters for Postgres, Redis, and S3, plus a conformance suite), and sessions can be resumed from it on another host.
17
22
  - **Same wire protocol as the official SDKs.** Spawns the `claude` CLI as a subprocess and speaks stream-JSON over stdin/stdout, so every feature of the runtime is available: sessions, subagents, sandboxing, structured output, file checkpointing and rewind.
18
23
  - **`query()` for one-shot calls, `Client` for bidirectional sessions** with interrupts, mid-session model switching, and streaming input from any `Enumerator`.
19
24
  - **In-process custom tools.** Define tools as Ruby blocks; they run inside your process with direct access to your app state (SDK MCP servers), with JSON-Schema-validated arguments.
20
25
  - **All 27 hook events and permission callbacks** with typed inputs, so you can gate, audit, or rewrite every tool call.
21
- - **Rails-ready.** Fiber-safe callback dispatch, an initializer-style `configure` block, ActionCable streaming, background-job session resumption, and a `callback_scheduling: :inline` mode for fiber workers.
22
- - **Built-in OpenTelemetry observer** with Langfuse support; no third-party instrumentation library required.
23
26
  - **Pluggable transport** to run the CLI somewhere else (an E2B microVM, a container, over SSH).
24
- - **Hermetic deploys.** `CLIInstaller` vendors a checksum-verified, pinned CLI binary into your project so production never depends on a global `npm install`.
25
27
 
26
28
  ## Installation
27
29
 
28
30
  ```ruby
29
31
  # Gemfile
30
- gem 'claude-agent-sdk', '~> 0.34.0'
32
+ gem 'claude-agent-sdk', '~> 0.36.0'
31
33
  ```
32
34
 
33
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'`.
@@ -44,19 +46,52 @@ ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220') # => "/app/vendor/clau
44
46
 
45
47
  The vendored binary is found ahead of `PATH`, installs are idempotent and concurrency-safe, and a failed upgrade never breaks a working install. See [docs/cli-installer.md](docs/cli-installer.md) for the full behaviour, supported platforms, and the CLI discovery order.
46
48
 
49
+ ### Rails in a minute
50
+
51
+ ```bash
52
+ bundle add claude-agent-sdk
53
+ bin/rails generate claude_agent_sdk:install # config/initializers/claude_agent_sdk.rb + .gitignore entry
54
+ bin/rails claude_agent_sdk:install_cli # the tested CLI into vendor/claude (also a Docker build step)
55
+ ```
56
+
57
+ ```ruby
58
+ # app/jobs/summarize_ticket_job.rb
59
+ class SummarizeTicketJob < ApplicationJob
60
+ def perform(ticket)
61
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: [], max_turns: 1)
62
+ prompt = "Summarize this support ticket in two sentences:\n\n#{ticket.body}"
63
+
64
+ ClaudeAgentSDK.query(prompt: prompt, options: options) do |message|
65
+ ticket.update!(summary: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage)
66
+ end
67
+ end
68
+ end
69
+ ```
70
+
71
+ The block runs on a plain thread, so ActiveRecord calls inside it just work. [docs/rails.md](docs/rails.md) continues with multi-turn sessions, ActionCable streaming, and fiber workers.
72
+
47
73
  ## Quick Start
48
74
 
49
75
  ```ruby
50
76
  require 'claude_agent_sdk'
51
77
 
52
- 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|
53
85
  puts message.text if message.is_a?(ClaudeAgentSDK::AssistantMessage)
54
86
  end
87
+ puts result
55
88
  ```
56
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
+
57
92
  ### `query()` — one-shot and streaming
58
93
 
59
- `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`.
60
95
 
61
96
  ```ruby
62
97
  options = ClaudeAgentSDK::ClaudeAgentOptions.new(
@@ -84,23 +119,31 @@ end
84
119
 
85
120
  ### `Client` — bidirectional sessions
86
121
 
87
- `Client` keeps a session open so you can send follow-up queries, interrupt, switch models, and use hooks, permission callbacks, and custom tools. It runs inside an [`async`](https://github.com/socketry/async) block; blocking calls yield automatically, no `await` needed.
122
+ `Client` keeps a session open so you can send follow-up queries, interrupt, switch models, and use hooks, permission callbacks, and custom tools. `Client.open` connects, yields the client, and always disconnects when the block exits, even on an exception. It returns the block's value.
88
123
 
89
124
  ```ruby
90
125
  require 'claude_agent_sdk'
91
- require 'async'
92
126
 
93
- Async do
94
- client = ClaudeAgentSDK::Client.new
127
+ ClaudeAgentSDK::Client.open do |client|
128
+ client.query("What is the capital of France?")
129
+ client.receive_response { |msg| puts msg }
95
130
 
96
- begin
97
- client.connect
98
- client.query("What is the capital of France?")
99
- client.receive_response { |msg| puts msg }
100
- ensure
101
- client.disconnect
102
- end
103
- end.wait
131
+ client.query("And of Germany?")
132
+ client.receive_response { |msg| puts msg }
133
+ end
134
+ ```
135
+
136
+ `Client.open` creates an [`async`](https://github.com/socketry/async) reactor when there isn't one; blocking calls yield automatically, no `await` needed. Called outside a reactor, `break` inside the block raises `LocalJumpError` (the client still disconnects), so return a value instead. Code that is already running inside an `Async` reactor can also manage the lifecycle by hand:
137
+
138
+ ```ruby
139
+ client = ClaudeAgentSDK::Client.new
140
+ begin
141
+ client.connect
142
+ client.query("What is the capital of France?")
143
+ client.receive_response { |msg| puts msg }
144
+ ensure
145
+ client.disconnect
146
+ end
104
147
  ```
105
148
 
106
149
  See [docs/client.md](docs/client.md) for `interrupt`, mid-session model and permission switching, MCP status, and custom transports.
@@ -111,7 +154,7 @@ Tools are Ruby blocks that run in-process, with no subprocess or IPC between Cla
111
154
 
112
155
  ```ruby
113
156
  greet = ClaudeAgentSDK.create_tool('greet', 'Greet a user', { name: :string }) do |args|
114
- { content: [{ type: 'text', text: "Hello, #{args[:name]}!" }] }
157
+ "Hello, #{args[:name]}!"
115
158
  end
116
159
 
117
160
  server = ClaudeAgentSDK.create_sdk_mcp_server(name: 'my-tools', tools: [greet])
@@ -122,7 +165,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
122
165
  )
123
166
  ```
124
167
 
125
- 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.
126
169
 
127
170
  ### Hooks and permission callbacks
128
171
 
@@ -148,7 +191,7 @@ See [docs/hooks-and-permissions.md](docs/hooks-and-permissions.md) for the full
148
191
  | Session listing, reading, renaming, tagging, forking, resume-at-message | [docs/sessions.md](docs/sessions.md) |
149
192
  | Subagent capabilities, event contracts, and minimal example | [docs/subagents.md](docs/subagents.md) |
150
193
  | OpenTelemetry tracing, Langfuse, custom observers | [docs/observability.md](docs/observability.md) |
151
- | Rails: fiber safety, solid_queue fiber workers, ActionCable, jobs, initializer | [docs/rails.md](docs/rails.md) |
194
+ | Rails: generator, `install_cli` task, callback wrapper, fiber safety, solid_queue fiber workers, ActionCable, jobs | [docs/rails.md](docs/rails.md) |
152
195
  | Vendoring a pinned CLI binary and CLI discovery order | [docs/cli-installer.md](docs/cli-installer.md) |
153
196
  | Message, content block, and configuration type reference | [docs/types.md](docs/types.md) |
154
197
  | Error handling, exception hierarchy, timeouts | [docs/errors.md](docs/errors.md) |
@@ -210,13 +253,14 @@ bundle install
210
253
  bundle exec rspec # unit suite
211
254
  bundle exec rubocop # lint
212
255
  RUN_INTEGRATION=1 bundle exec rspec # also run the real-CLI integration suite (needs `claude` and ANTHROPIC_API_KEY)
256
+ BUNDLE_GEMFILE=gemfiles/rails_8.gemfile bundle exec rspec --options spec/rails/.rspec # Rails integration specs
213
257
  ```
214
258
 
215
- CI runs the suite and RuboCop on Ruby 3.2, 3.3, and 3.4. 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.
216
260
 
217
261
  ## Contributing
218
262
 
219
- 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).
220
264
 
221
265
  ## License
222
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`
@@ -51,6 +65,29 @@ version = ENV.fetch('CLAUDE_CLI_VERSION', ClaudeAgentSDK::CLIInstaller::PINNED_C
51
65
  puts ClaudeAgentSDK::CLIInstaller.install(version: version)
52
66
  ```
53
67
 
68
+ ## Rake task
69
+
70
+ Rails apps get `claude_agent_sdk:install_cli` from the gem's Railtie; any other project can load it from its `Rakefile`:
71
+
72
+ ```ruby
73
+ # Rakefile (non-Rails)
74
+ require 'claude_agent_sdk/tasks' # loads only CLIInstaller, not the whole SDK
75
+ ```
76
+
77
+ ```bash
78
+ bin/rails claude_agent_sdk:install_cli # Rails: installs PINNED_CLI_VERSION into Rails.root/vendor/claude
79
+ rake claude_agent_sdk:install_cli # elsewhere: into CLIInstaller.default_dir (vendor/claude under the working directory unless root is set)
80
+ rake claude_agent_sdk:install_cli CLAUDE_CLI_VERSION=x.y.z # a version of your own, or 'stable' / 'latest'
81
+ ```
82
+
83
+ The task calls `install_pinned` (or `install(version:)` when `CLAUDE_CLI_VERSION` is set — the same variable the `bin/setup` example above reads), prints the installed path, and doesn't boot the Rails app, so it runs in a Docker build without a database or credentials:
84
+
85
+ ```dockerfile
86
+ RUN bin/rails claude_agent_sdk:install_cli
87
+ ```
88
+
89
+ The variable is deliberately not rake's conventional `VERSION`, which Rails' `db:migrate` uses and build environments often export for an app version or git SHA. An empty `CLAUDE_CLI_VERSION` means the gem's pin.
90
+
54
91
  ## Supported platforms
55
92
 
56
93
  `darwin-arm64`, `darwin-x64` (Rosetta 2 gets the arm64 build), `linux-x64`, `linux-arm64`, and the `-musl` variants. Windows is not supported.
@@ -60,6 +97,6 @@ puts ClaudeAgentSDK::CLIInstaller.install(version: version)
60
97
  With no explicit `cli_path:` in `ClaudeAgentOptions`, the transport probes in this order:
61
98
 
62
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:`)
63
- 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
64
101
  3. `which claude`
65
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,31 +2,42 @@
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
 
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.
10
+
7
11
  ```ruby
8
12
  require 'claude_agent_sdk'
9
- require 'async'
10
-
11
- Async do
12
- client = ClaudeAgentSDK::Client.new
13
13
 
14
- begin
15
- client.connect
16
- client.query("What is the capital of France?")
14
+ ClaudeAgentSDK::Client.open do |client|
15
+ client.query("What is the capital of France?")
17
16
 
18
- client.receive_response do |msg|
19
- case msg
20
- when ClaudeAgentSDK::AssistantMessage
21
- puts msg.text
22
- when ClaudeAgentSDK::ResultMessage
23
- puts "Cost: $#{msg.total_cost_usd}" if msg.total_cost_usd
24
- end
17
+ client.receive_response do |msg|
18
+ case msg
19
+ when ClaudeAgentSDK::AssistantMessage
20
+ puts msg.text
21
+ when ClaudeAgentSDK::ResultMessage
22
+ puts "Cost: $#{msg.total_cost_usd}" if msg.total_cost_usd
25
23
  end
26
- ensure
27
- client.disconnect
28
24
  end
29
- end.wait
25
+ end
26
+ ```
27
+
28
+ Called outside a reactor, `break` inside the `Client.open` block raises `LocalJumpError` (the client still disconnects), so return a value from the block instead. `break` inside `receive_response` / `receive_messages` is fine. It stops the iteration.
29
+
30
+ If your code already runs inside an `Async` reactor and you want to manage the connection yourself, call `connect` and `disconnect` directly:
31
+
32
+ ```ruby
33
+ client = ClaudeAgentSDK::Client.new
34
+ begin
35
+ client.connect
36
+ client.query("What is the capital of France?")
37
+ client.receive_response { |msg| puts msg }
38
+ ensure
39
+ client.disconnect
40
+ end
30
41
  ```
31
42
 
32
43
  ## Advanced Features
@@ -37,9 +48,10 @@ Async do
37
48
  client.connect
38
49
 
39
50
  client.interrupt # Send interrupt signal
40
- client.set_permission_mode('acceptEdits') # Change permission mode mid-conversation
41
- client.set_model('claude-sonnet-5') # Switch model mid-conversation
42
- 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
43
55
  info = client.get_server_info # Inspect server init info
44
56
  client.reconnect_mcp_server('my-server') # Reconnect a failed MCP server
45
57
  client.toggle_mcp_server('my-server', false) # Enable/disable an MCP server
@@ -53,6 +65,18 @@ Async do
53
65
  end.wait
54
66
  ```
55
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
+
56
80
  ## Custom Transport
57
81
 
58
82
  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).
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) |
@@ -193,3 +193,25 @@ never raises — shadowing can be intentional, e.g. a callback used solely for
193
193
  tools outside `allowed_tools`. To gate every tool call including
194
194
  auto-approved ones, use a `PreToolUse` hook instead (note that a `PreToolUse`
195
195
  hook returning an allow decision also skips this callback).
196
+
197
+ ## When a callback raises
198
+
199
+ An exception raised inside a hook or a `can_use_tool` callback fails that
200
+ control request: the CLI receives an error response carrying the exception
201
+ message, and the session carries on with later requests. The request's
202
+ cancellation signal is invalidated, as for any other callback failure.
203
+
204
+ `exit`, `Interrupt` and other signal exceptions are never swallowed. If one
205
+ is raised while a callback runs (by the callback itself, or a real Ctrl-C /
206
+ `SIGTERM` arriving while an `:inline` callback runs on the main thread), the
207
+ CLI first gets the same error response, naming the exception class
208
+ (`"SystemExit: exit"`, `"Interrupt"`), and then the exception propagates as
209
+ Ruby normally would: `exit 3` ends the process with status 3, and Ctrl-C
210
+ interrupts it. This holds in both `:thread` and `:inline` scheduling, and also
211
+ when a `:thread` hook calls `exit` after its `HookMatcher#timeout` has already
212
+ expired. A `callback_wrapper` sees the exception wrapped in an internal
213
+ `StandardError` whose `#cause` is the original, so ensure-based wrappers (such
214
+ as `Rails.application.executor.wrap`) still clean up. The original is raised
215
+ again after the wrapper returns, even if the wrapper swallows the error.
216
+ Cancellation (`control_cancel_request`, `HookMatcher#timeout`, disconnect)
217
+ 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,13 @@ 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` and `#handle_message` simply let such exceptions
134
+ propagate. Cancellation of the tool call itself still propagates.
111
135
 
112
136
  ## Mixed Server Support
113
137
 
@@ -172,4 +196,10 @@ server = ClaudeAgentSDK.create_sdk_mcp_server(
172
196
  )
173
197
  ```
174
198
 
199
+ An exception raised inside a resource reader or prompt generator is answered
200
+ with a JSON-RPC internal error (`-32603`) carrying the exception message. For
201
+ `exit`, `Interrupt` and other signal exceptions the CLI gets that error first,
202
+ naming the exception class, and then the exception propagates as Ruby normally
203
+ would.
204
+
175
205
  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.