claude-agent-sdk 0.34.0 → 0.35.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff96202bf91fc547d93ae77a7b022fb83a0651574fcb1aae5e521531e33ea9e7
4
- data.tar.gz: 6956e7943d0856d9ac5975a7ba01e779159df1193301199dc0f082bf80835d88
3
+ metadata.gz: a2976dae089e4ccc32b7974b646cb9e543c4dcdfd6bfb35eeccf6b3c4ef5e6ed
4
+ data.tar.gz: 89ec51407ce6f25c962982078b3fe8301bfda9be4e57bf4a03593e9818d5913a
5
5
  SHA512:
6
- metadata.gz: 9e64d2bf6a83ae79b2f77007baad0d9ff340e4ff0b264d1acc066660860e57e329af84406bc041d39af73c1030afebf96d9f2a7894038354fdf6ce9847447e6c
7
- data.tar.gz: 8e0e4e08914e30d812094a7a7cbfe4c0f128f7f83b3856a5947149b84566fd9ec72c8c9c3477778e3c196aba6694fb3524e84b3d4d1c1a919f1683aa545a271a
6
+ metadata.gz: 4784c6381acbfedd9a921a852329dfdd64257aa60b25bf51b0f4278b5fe6ce4913b1f719941a0fece9ac818342e5129c0ffd481159bf731ee6930ecf4902b632
7
+ data.tar.gz: cee7e96d3ef502138189a114716db3862d10d2ff10411e04bf0db8bc654511c547397164c7640c590eed166d11556c26b5d4e6de744f3a6ac396cdb15e40bad3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.35.0] - 2026-09-23
11
+
12
+ 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**).
13
+
14
+ ### Added
15
+ - **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.
16
+ - **`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.
17
+ - **`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.
18
+ - **`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+.
19
+ - 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`.
20
+ - **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.
21
+ - **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.
22
+
23
+ ### Changed
24
+ - `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.
25
+ - **`#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`.
26
+ - 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.
27
+ - The bundled `claude-agent-ruby` skill recommends `Client.open` and documents the install generator, the `install_cli` task and `Railtie.callback_wrapper`.
28
+ - **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.
29
+
30
+ ### Fixed
31
+ - **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.
32
+
10
33
  ## [0.34.0] - 2026-09-23
11
34
 
12
35
  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.35.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,6 +46,30 @@ 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
@@ -84,23 +110,31 @@ end
84
110
 
85
111
  ### `Client` — bidirectional sessions
86
112
 
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.
113
+ `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
114
 
89
115
  ```ruby
90
116
  require 'claude_agent_sdk'
91
- require 'async'
92
117
 
93
- Async do
94
- client = ClaudeAgentSDK::Client.new
118
+ ClaudeAgentSDK::Client.open do |client|
119
+ client.query("What is the capital of France?")
120
+ client.receive_response { |msg| puts msg }
95
121
 
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
122
+ client.query("And of Germany?")
123
+ client.receive_response { |msg| puts msg }
124
+ end
125
+ ```
126
+
127
+ `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:
128
+
129
+ ```ruby
130
+ client = ClaudeAgentSDK::Client.new
131
+ begin
132
+ client.connect
133
+ client.query("What is the capital of France?")
134
+ client.receive_response { |msg| puts msg }
135
+ ensure
136
+ client.disconnect
137
+ end
104
138
  ```
105
139
 
106
140
  See [docs/client.md](docs/client.md) for `interrupt`, mid-session model and permission switching, MCP status, and custom transports.
@@ -148,7 +182,7 @@ See [docs/hooks-and-permissions.md](docs/hooks-and-permissions.md) for the full
148
182
  | Session listing, reading, renaming, tagging, forking, resume-at-message | [docs/sessions.md](docs/sessions.md) |
149
183
  | Subagent capabilities, event contracts, and minimal example | [docs/subagents.md](docs/subagents.md) |
150
184
  | 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) |
185
+ | Rails: generator, `install_cli` task, callback wrapper, fiber safety, solid_queue fiber workers, ActionCable, jobs | [docs/rails.md](docs/rails.md) |
152
186
  | Vendoring a pinned CLI binary and CLI discovery order | [docs/cli-installer.md](docs/cli-installer.md) |
153
187
  | Message, content block, and configuration type reference | [docs/types.md](docs/types.md) |
154
188
  | Error handling, exception hierarchy, timeouts | [docs/errors.md](docs/errors.md) |
@@ -210,9 +244,10 @@ bundle install
210
244
  bundle exec rspec # unit suite
211
245
  bundle exec rubocop # lint
212
246
  RUN_INTEGRATION=1 bundle exec rspec # also run the real-CLI integration suite (needs `claude` and ANTHROPIC_API_KEY)
247
+ BUNDLE_GEMFILE=gemfiles/rails_8.gemfile bundle exec rspec --options spec/rails/.rspec # Rails integration specs
213
248
  ```
214
249
 
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.
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.
216
251
 
217
252
  ## Contributing
218
253
 
@@ -51,6 +51,29 @@ version = ENV.fetch('CLAUDE_CLI_VERSION', ClaudeAgentSDK::CLIInstaller::PINNED_C
51
51
  puts ClaudeAgentSDK::CLIInstaller.install(version: version)
52
52
  ```
53
53
 
54
+ ## Rake task
55
+
56
+ Rails apps get `claude_agent_sdk:install_cli` from the gem's Railtie; any other project can load it from its `Rakefile`:
57
+
58
+ ```ruby
59
+ # Rakefile (non-Rails)
60
+ require 'claude_agent_sdk/tasks' # loads only CLIInstaller, not the whole SDK
61
+ ```
62
+
63
+ ```bash
64
+ 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
66
+ rake claude_agent_sdk:install_cli CLAUDE_CLI_VERSION=x.y.z # a version of your own, or 'stable' / 'latest'
67
+ ```
68
+
69
+ 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:
70
+
71
+ ```dockerfile
72
+ RUN bin/rails claude_agent_sdk:install_cli
73
+ ```
74
+
75
+ 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.
76
+
54
77
  ## Supported platforms
55
78
 
56
79
  `darwin-arm64`, `darwin-x64` (Rosetta 2 gets the arm64 build), `linux-x64`, `linux-arm64`, and the `-musl` variants. Windows is not supported.
data/docs/client.md CHANGED
@@ -4,29 +4,38 @@
4
4
 
5
5
  ## Basic Usage
6
6
 
7
+ `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.
8
+
7
9
  ```ruby
8
10
  require 'claude_agent_sdk'
9
- require 'async'
10
11
 
11
- Async do
12
- client = ClaudeAgentSDK::Client.new
12
+ ClaudeAgentSDK::Client.open do |client|
13
+ client.query("What is the capital of France?")
13
14
 
14
- begin
15
- client.connect
16
- client.query("What is the capital of France?")
17
-
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
15
+ client.receive_response do |msg|
16
+ case msg
17
+ when ClaudeAgentSDK::AssistantMessage
18
+ puts msg.text
19
+ when ClaudeAgentSDK::ResultMessage
20
+ puts "Cost: $#{msg.total_cost_usd}" if msg.total_cost_usd
25
21
  end
26
- ensure
27
- client.disconnect
28
22
  end
29
- end.wait
23
+ end
24
+ ```
25
+
26
+ 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.
27
+
28
+ If your code already runs inside an `Async` reactor and you want to manage the connection yourself, call `connect` and `disconnect` directly:
29
+
30
+ ```ruby
31
+ client = ClaudeAgentSDK::Client.new
32
+ begin
33
+ client.connect
34
+ client.query("What is the capital of France?")
35
+ client.receive_response { |msg| puts msg }
36
+ ensure
37
+ client.disconnect
38
+ end
30
39
  ```
31
40
 
32
41
  ## Advanced Features
data/docs/rails.md CHANGED
@@ -1,6 +1,49 @@
1
1
  # Rails Integration
2
2
 
3
- The SDK integrates well with Rails applications. Below are the common patterns.
3
+ The gem ships a Railtie, an install generator and a rake task for vendoring the CLI; the rest of this page covers how SDK callbacks interact with Rails' threading, executor and fiber workers, and the common job / ActionCable patterns.
4
+
5
+ ## Getting started
6
+
7
+ 1. Add the gem:
8
+
9
+ ```bash
10
+ bundle add claude-agent-sdk
11
+ ```
12
+
13
+ 2. Generate the initializer:
14
+
15
+ ```bash
16
+ bin/rails generate claude_agent_sdk:install
17
+ ```
18
+
19
+ This writes `config/initializers/claude_agent_sdk.rb` — a `ClaudeAgentSDK.configure` block with commented defaults (model, permission mode, CLI path, OpenTelemetry) and the Rails callback wrapper [described below](#rails-executor-around-callbacks-callback_wrapper) switched on — and adds `/vendor/claude/` to `.gitignore`.
20
+
21
+ 3. Vendor the Claude Code CLI:
22
+
23
+ ```bash
24
+ bin/rails claude_agent_sdk:install_cli # the version this gem release is tested with
25
+ bin/rails claude_agent_sdk:install_cli CLAUDE_CLI_VERSION=x.y.z # or a version of your own ('stable' / 'latest' float)
26
+ ```
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`.
29
+
30
+ 4. Run an agent from a job:
31
+
32
+ ```ruby
33
+ # app/jobs/summarize_ticket_job.rb
34
+ class SummarizeTicketJob < ApplicationJob
35
+ def perform(ticket)
36
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: [], max_turns: 1) # text only, no built-in tools
37
+ prompt = "Summarize this support ticket in two sentences:\n\n#{ticket.body}"
38
+
39
+ ClaudeAgentSDK.query(prompt: prompt, options: options) do |message|
40
+ ticket.update!(summary: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage)
41
+ end
42
+ end
43
+ end
44
+ ```
45
+
46
+ The block runs on a plain thread (see the next section), so ActiveRecord calls inside it just work. For multi-turn sessions, hooks, custom tools and interrupts use `ClaudeAgentSDK::Client.open` — see [ActionCable streaming](#actioncable-streaming) below.
4
47
 
5
48
  ## Thread-keyed libraries are safe inside SDK callbacks
6
49
 
@@ -23,17 +66,25 @@ The trade-off: because callbacks run on a plain thread rather than inside an `As
23
66
 
24
67
  ### Rails executor around callbacks: `callback_wrapper`
25
68
 
26
- One consequence of the thread hop: an ActiveRecord connection implicitly checked out inside a callback belongs to that throwaway thread and stays stranded until the pool reaper reclaims it. Rails' own answer to "code running on a thread Rails didn't create" is the executor — and `callback_wrapper` lets you install it around every user-callback dispatch:
69
+ One consequence of the thread hop: an ActiveRecord connection implicitly checked out inside a callback belongs to that throwaway thread and stays stranded until the pool reaper reclaims it. Rails' own answer to "code running on a thread Rails didn't create" is the executor — and `callback_wrapper` lets you install it around every user-callback dispatch. Use the SDK's Rails-aware wrapper (the generated initializer already does):
27
70
 
28
71
  ```ruby
29
72
  ClaudeAgentSDK.configure do |config|
30
73
  config.default_options = {
31
- callback_wrapper: ->(invocation) { Rails.application.executor.wrap { invocation.call } }
74
+ callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper
32
75
  }
33
76
  end
34
77
  ```
35
78
 
36
- The wrapper is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: `executor.wrap` runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers like `executor.wrap` are safe, including around a `break` from a message block. Beyond the executor, this is a generic hook for APM span propagation, `CurrentAttributes`/logging context, etc.
79
+ It runs each callback inside `Rails.application.executor.wrap` — except where that would deadlock, which is why it replaces the bare `->(invocation) { Rails.application.executor.wrap { invocation.call } }` this guide used to recommend:
80
+
81
+ - **Development (code reloading enabled).** Every executor then holds a share of the code-reload interlock. The request or job calling the SDK is already inside the executor, and in `:thread` mode it waits for the callback's thread. If a reload is requested meanwhile (say, another request arrives after the agent edited an app file), the reloader queues for the exclusive unload lock, and a callback thread entering `executor.wrap` queues behind it for a fresh share — which the reloader can never let through while the waiting caller holds its own. Everything hangs. The helper instead runs the callback without entering the executor (the caller's share still keeps code from being unloaded under it) and returns the thread's ActiveRecord connections to the pool when the callback finishes. The executor's other per-run hooks (query cache, `CurrentAttributes` reset) do not run for callbacks in this case.
82
+ - **`config.allow_concurrency = false`.** The executor holds a process-wide monitor that the calling thread already owns, so a callback thread's `executor.wrap` would block every time; same treatment.
83
+ - **Already inside the executor** (`:inline` scheduling on a job's own fiber): the callback runs straight through, leaving cleanup to the enclosing executor.
84
+
85
+ Everywhere else — production, with no reloading — it is exactly `executor.wrap`. The configuration is read per call, so one initializer is correct in every environment.
86
+
87
+ Writing your own wrapper: it is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: `executor.wrap` runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers like `executor.wrap` are safe, including around a `break` from a message block. Beyond the executor, this is a generic hook for APM span propagation, `CurrentAttributes`/logging context, etc. — to combine one with the Rails wrapper, call it from yours: `rails = ClaudeAgentSDK::Railtie.callback_wrapper` then `->(inv) { MyApm.trace { rails.call(inv) } }`.
37
88
 
38
89
  The wrapper also composes around every timeout-bounded `SessionStore` adapter call (mirror-batcher appends, resume-materialization loads and listings), inside the timeout bound — so an ActiveRecord-backed store adapter gets the same connection hygiene as your callbacks.
39
90
 
@@ -96,38 +147,33 @@ class ChatAgentJob < ApplicationJob
96
147
  queue_as :claude_agents
97
148
 
98
149
  def perform(chat_id, message_content)
99
- Async do
100
- options = ClaudeAgentSDK::ClaudeAgentOptions.new(
101
- system_prompt: { type: 'preset', preset: 'claude_code' },
102
- permission_mode: 'bypassPermissions'
103
- )
150
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
151
+ system_prompt: { type: 'preset', preset: 'claude_code' },
152
+ permission_mode: 'bypassPermissions'
153
+ )
104
154
 
105
- client = ClaudeAgentSDK::Client.new(options: options)
106
-
107
- begin
108
- client.connect
109
- client.query(message_content)
110
-
111
- client.receive_response do |message|
112
- case message
113
- when ClaudeAgentSDK::AssistantMessage
114
- ChatChannel.broadcast_to(chat_id, { type: 'chunk', content: message.text })
115
- when ClaudeAgentSDK::ResultMessage
116
- ChatChannel.broadcast_to(chat_id, {
117
- type: 'complete',
118
- content: message.result,
119
- cost: message.total_cost_usd
120
- })
121
- end
155
+ ClaudeAgentSDK::Client.open(options: options) do |client|
156
+ client.query(message_content)
157
+
158
+ client.receive_response do |message|
159
+ case message
160
+ when ClaudeAgentSDK::AssistantMessage
161
+ ChatChannel.broadcast_to(chat_id, { type: 'chunk', content: message.text })
162
+ when ClaudeAgentSDK::ResultMessage
163
+ ChatChannel.broadcast_to(chat_id, {
164
+ type: 'complete',
165
+ content: message.result,
166
+ cost: message.total_cost_usd
167
+ })
122
168
  end
123
- ensure
124
- client.disconnect
125
169
  end
126
- end.wait
170
+ end
127
171
  end
128
172
  end
129
173
  ```
130
174
 
175
+ `Client.open` connects, yields the client, and always disconnects — also when the block raises, so the job's error handling sees the original exception. It runs inside an existing reactor or starts its own, so a job needs no `Async { }.wait` wrapper. Its return value is the block's; to leave the block early use `next`, not `break` (outside an `Async` block, `break` raises `LocalJumpError`, though the session is still torn down). `break` inside `receive_response` itself is fine.
176
+
131
177
  ## Session Resumption
132
178
 
133
179
  Persist Claude sessions for multi-turn conversations:
@@ -138,19 +184,13 @@ class ChatSession < ApplicationRecord
138
184
  # Columns: id, claude_session_id, user_id, created_at, updated_at
139
185
 
140
186
  def send_message(content)
141
- options = build_options
142
- client = ClaudeAgentSDK::Client.new(options: options)
143
-
144
- Async do
145
- client.connect
146
- client.query(content, session_id: claude_session_id ? nil : generate_session_id)
187
+ ClaudeAgentSDK::Client.open(options: build_options) do |client|
188
+ client.query(content)
147
189
 
148
190
  client.receive_response do |message|
149
191
  update!(claude_session_id: message.session_id) if message.is_a?(ClaudeAgentSDK::ResultMessage)
150
192
  end
151
- ensure
152
- client.disconnect
153
- end.wait
193
+ end
154
194
  end
155
195
 
156
196
  private
@@ -160,13 +200,11 @@ class ChatSession < ApplicationRecord
160
200
  opts[:resume] = claude_session_id if claude_session_id.present?
161
201
  ClaudeAgentSDK::ClaudeAgentOptions.new(**opts)
162
202
  end
163
-
164
- def generate_session_id
165
- "chat_#{id}_#{Time.current.to_i}"
166
- end
167
203
  end
168
204
  ```
169
205
 
206
+ The first message starts a new session; every later one resumes it by the ID the previous `ResultMessage` reported.
207
+
170
208
  ## Background Jobs with Error Handling
171
209
 
172
210
  ```ruby
@@ -176,17 +214,17 @@ class ClaudeAgentJob < ApplicationJob
176
214
 
177
215
  def perform(task_id)
178
216
  task = Task.find(task_id)
179
- Async { execute_agent(task) }.wait
217
+
218
+ ClaudeAgentSDK::Client.open(options: ClaudeAgentSDK::ClaudeAgentOptions.new(max_turns: 10)) do |client|
219
+ client.query(task.prompt)
220
+ client.receive_response do |message|
221
+ task.update!(status: 'done', result: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage)
222
+ end
223
+ end
180
224
  rescue ClaudeAgentSDK::CLINotFoundError
181
- task.update!(status: 'failed', error: 'Claude CLI not installed')
225
+ task.update!(status: 'failed', error: 'Claude CLI not installed (bin/rails claude_agent_sdk:install_cli)')
182
226
  raise
183
227
  end
184
-
185
- private
186
-
187
- def execute_agent(task)
188
- # ... agent execution
189
- end
190
228
  end
191
229
  ```
192
230
 
@@ -250,7 +288,8 @@ ClaudeAgentSDK.configure do |config|
250
288
  # Use a lambda so each query gets a fresh observer instance (thread-safe).
251
289
  # A single shared instance would have its span state clobbered by concurrent requests.
252
290
  -> { ClaudeAgentSDK::Instrumentation::OTelObserver.new }
253
- ] : []
291
+ ] : [],
292
+ callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper
254
293
  }
255
294
  end
256
295
  ```
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # Rails integration. Loaded by lib/claude_agent_sdk.rb only when
5
+ # Rails::Railtie is already defined (Bundler.require runs after
6
+ # `require 'rails'`), so non-Rails processes never see it.
7
+ #
8
+ # Deliberately minimal: it contributes the `claude_agent_sdk:*` rake tasks
9
+ # and nothing else. It installs nothing into callback dispatch — the
10
+ # generated initializer (`bin/rails g claude_agent_sdk:install`) opts in
11
+ # to {.callback_wrapper} explicitly, where it is visible and removable.
12
+ class Railtie < ::Rails::Railtie
13
+ rake_tasks do
14
+ load File.expand_path('tasks/claude_agent_sdk.rake', __dir__)
15
+ end
16
+
17
+ # A `callback_wrapper` (see ClaudeAgentOptions#callback_wrapper) that
18
+ # gives SDK callbacks Rails' connection hygiene without deadlocking
19
+ # development code reloading.
20
+ #
21
+ # The obvious wrapper, `->(inv) { Rails.application.executor.wrap { inv.call } }`,
22
+ # deadlocks whenever the executor carries a process-wide lock:
23
+ #
24
+ # - With code reloading enabled, every executor holds a share of the
25
+ # ActiveSupport::Dependencies interlock. In the default `:thread`
26
+ # scheduling the request/job thread (already inside the executor, so
27
+ # already holding a share) blocks on the FiberBoundary thread running
28
+ # the callback. If a reload starts meanwhile (another request after the
29
+ # agent edited an app file), the reloader queues for the exclusive
30
+ # unload lock, and the callback thread's own `executor.wrap` then waits
31
+ # behind it for a new share — which the reloader can never get while
32
+ # the parent's share is held. Three-way deadlock.
33
+ # - With `config.allow_concurrency = false`, the executor holds a
34
+ # process-wide monitor that the parent thread already owns, so the
35
+ # callback thread's `executor.wrap` blocks every time.
36
+ #
37
+ # So, per invocation:
38
+ #
39
+ # 1. Executor already active on this execution context (`:inline`
40
+ # scheduling, or a callback running on the caller's own thread) —
41
+ # call straight through; the enclosing executor already owns cleanup.
42
+ # 2. Executor carries a lock (the two cases above, mirroring railties'
43
+ # `configure_executor_for_concurrency`) — call WITHOUT entering the
44
+ # executor, then return this thread's ActiveRecord connections to the
45
+ # pool. The caller's executor still covers the callback: its share of
46
+ # the interlock keeps a reload from unloading code under it until the
47
+ # whole SDK call returns.
48
+ # 3. Otherwise (production: no reloading, concurrency allowed) — run the
49
+ # callback inside `Rails.application.executor.wrap`.
50
+ #
51
+ # The configuration is read on every call, so the same wrapper is correct
52
+ # in every environment.
53
+ #
54
+ # @return [Proc] a callable suitable for `callback_wrapper:`
55
+ # @example config/initializers/claude_agent_sdk.rb
56
+ # ClaudeAgentSDK.configure do |config|
57
+ # config.default_options = { callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper }
58
+ # end
59
+ def self.callback_wrapper
60
+ lambda do |invocation|
61
+ app = ::Rails.application
62
+ next invocation.call if app.nil? || app.executor.active?
63
+ next app.executor.wrap { invocation.call } unless executor_locks?(app.config)
64
+
65
+ begin
66
+ invocation.call
67
+ ensure
68
+ release_active_record_connections
69
+ end
70
+ end
71
+ end
72
+
73
+ # Whether railties registered a process-wide lock hook on the executor
74
+ # (Rails::Application::Finisher, initializer
75
+ # :configure_executor_for_concurrency).
76
+ def self.executor_locks?(config)
77
+ return true if config.allow_concurrency == false
78
+ return false if config.allow_concurrency == :unsafe
79
+
80
+ config.reloading_enabled?
81
+ end
82
+ private_class_method :executor_locks?
83
+
84
+ # What the executor's ActiveRecord completion hook does for a callback
85
+ # that ran on a thread of its own. Explicit :all — the no-argument form
86
+ # is deprecated on Rails 7.1.
87
+ def self.release_active_record_connections
88
+ return unless defined?(::ActiveRecord::Base)
89
+
90
+ ::ActiveRecord::Base.connection_handler.clear_active_connections!(:all)
91
+ end
92
+ private_class_method :release_active_record_connections
93
+ end
94
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # claude_agent_sdk:* tasks. Loaded by ClaudeAgentSDK::Railtie in Rails apps
4
+ # and by `require 'claude_agent_sdk/tasks'` from a plain Rakefile. Guarded
5
+ # because Rake appends the actions of a task defined twice — an app that
6
+ # does both would otherwise install twice.
7
+ unless Rake::Task.task_defined?('claude_agent_sdk:install_cli')
8
+ namespace :claude_agent_sdk do
9
+ desc 'Install the Claude Code CLI into vendor/claude: the version this gem is tested with, ' \
10
+ 'or CLAUDE_CLI_VERSION=x.y.z / stable / latest'
11
+ task :install_cli do
12
+ # Under Rails, anchor to the app root instead of the process cwd (the
13
+ # app is not booted: no :environment dependency, so this runs in a
14
+ # Docker build without credentials). Resolved when the task runs.
15
+ root = Rails.root if defined?(Rails) && Rails.respond_to?(:root)
16
+ dir = root&.join('vendor', 'claude')&.to_s
17
+ # Not the conventional rake `VERSION`: Rails' own db:migrate uses it and
18
+ # build environments often export it for the app's version or git SHA.
19
+ version = ENV.fetch('CLAUDE_CLI_VERSION', '').strip
20
+
21
+ path = if version.empty?
22
+ ClaudeAgentSDK::CLIInstaller.install_pinned(dir: dir)
23
+ else
24
+ ClaudeAgentSDK::CLIInstaller.install(version: version, dir: dir)
25
+ end
26
+ label = version.empty? ? ClaudeAgentSDK::CLIInstaller::PINNED_CLI_VERSION : version
27
+ puts "Claude Code CLI (#{label}) installed at #{path}"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rake tasks for apps that don't use Rails (where the Railtie loads them).
4
+ # From a plain Rakefile:
5
+ #
6
+ # require 'claude_agent_sdk/tasks'
7
+ #
8
+ # then `rake claude_agent_sdk:install_cli` (e.g. in a Dockerfile). Only the
9
+ # stdlib-only CLIInstaller is loaded, not the whole SDK.
10
+ require 'rake'
11
+ require_relative 'cli_installer'
12
+
13
+ load File.expand_path('tasks/claude_agent_sdk.rake', __dir__)
@@ -157,8 +157,178 @@ module ClaudeAgentSDK
157
157
  end
158
158
  private_class_method :option_hash_key
159
159
 
160
+ # Bounded, human-oriented #inspect listing the non-nil instance variables
161
+ # in definition order:
162
+ #
163
+ # #<ClaudeAgentSDK::ResultMessage subtype="success" num_turns=3 ...>
164
+ #
165
+ # Messages carry whole transcripts, tool payloads and usage maps, so the
166
+ # output is bounded rather than faithful: long Strings are truncated,
167
+ # long Arrays/Hashes abbreviated, and nesting past INSPECT_MAX_DEPTH (or
168
+ # a reference cycle) collapses to a placeholder. Other objects keep their
169
+ # own #inspect (truncated) unless they only have Kernel#inspect, which
170
+ # dumps every ivar recursively — those (SDK MCP server instances, store
171
+ # adapters, observers) show as `#<ClassName>`. For display only: nothing
172
+ # sent to the CLI goes through #inspect or #to_s (wire output uses #to_h).
173
+ def inspect
174
+ inspect_with(0, {}.compare_by_identity)
175
+ end
176
+
177
+ # Object#to_s ignores instance variables, so `puts message` would print
178
+ # only a class name and an address. Types with a natural textual form
179
+ # (UserMessage, AssistantMessage, TextBlock, ResultMessage, SystemMessage)
180
+ # override this.
181
+ def to_s
182
+ inspect
183
+ end
184
+
185
+ # Declares attributes that carry credentials (env vars, auth headers).
186
+ # Objects get logged, so #inspect shows them filtered; #to_h and
187
+ # everything sent to the CLI are unaffected. Inherited by subclasses.
188
+ def self.inspect_filtered(*names)
189
+ @inspect_filtered_attributes = (inspect_filtered_attributes + names.map(&:to_s)).uniq.freeze
190
+ end
191
+
192
+ def self.inspect_filtered_attributes
193
+ @inspect_filtered_attributes || (superclass <= Type ? superclass.inspect_filtered_attributes : [].freeze)
194
+ end
195
+
196
+ INSPECT_MAX_STRING = 80
197
+ INSPECT_MAX_ITEMS = 5
198
+ INSPECT_MAX_DEPTH = 2
199
+ private_constant :INSPECT_MAX_STRING, :INSPECT_MAX_ITEMS, :INSPECT_MAX_DEPTH
200
+
201
+ protected
202
+
203
+ # `seen` holds the Types/containers on the current rendering path (not
204
+ # every one rendered so far), so a shared-but-acyclic value still renders
205
+ # in full wherever it appears.
206
+ def inspect_with(depth, seen)
207
+ return "#<#{inspect_class_name} …>" if depth > INSPECT_MAX_DEPTH || seen.key?(self)
208
+
209
+ seen[self] = true
210
+ begin
211
+ attributes = inspect_attributes.map do |name, value|
212
+ " #{name}=#{inspect_bounded(value, depth + 1, seen)}"
213
+ end
214
+ "#<#{inspect_class_name}#{attributes.join}>"
215
+ ensure
216
+ seen.delete(self)
217
+ end
218
+ end
219
+
160
220
  private
161
221
 
222
+ # [name, value] pairs rendered by #inspect. Subclasses override to hide
223
+ # redundant state or redact secrets — never by mutating the object.
224
+ def inspect_attributes
225
+ filtered = self.class.inspect_filtered_attributes
226
+ instance_variables.filter_map do |ivar|
227
+ value = instance_variable_get(ivar)
228
+ next if value.nil?
229
+
230
+ name = ivar.to_s.delete_prefix('@')
231
+ [name, filtered.include?(name) ? inspect_filter(value) : value]
232
+ end
233
+ end
234
+
235
+ # A credential-bearing Hash keeps its keys (useful when debugging which
236
+ # variables are set) with every value replaced; anything else is replaced
237
+ # outright. Builds a new Hash; the object itself is never touched.
238
+ def inspect_filter(value)
239
+ value.respond_to?(:each_key) ? value.each_key.to_h { |key| [key, '[FILTERED]'] } : '[FILTERED]'
240
+ end
241
+
242
+ def inspect_class_name
243
+ self.class.name || self.class.inspect
244
+ end
245
+
246
+ def inspect_bounded(value, depth, seen)
247
+ case value
248
+ when Type then value.inspect_with(depth, seen)
249
+ when String then inspect_truncated(value)
250
+ when Array then inspect_container(value, '[', ']', depth, seen) { |item| inspect_bounded(item, depth + 1, seen) }
251
+ when Hash
252
+ inspect_container(value, '{', '}', depth, seen) do |key, item|
253
+ "#{inspect_hash_key(key, depth + 1, seen)}#{inspect_bounded(item, depth + 1, seen)}"
254
+ end
255
+ when Proc, Method, UnboundMethod then inspect_callable(value)
256
+ else inspect_leaf(value)
257
+ end
258
+ end
259
+
260
+ def inspect_container(value, open, close, depth, seen, &render)
261
+ return "#{open}#{close}" if value.empty?
262
+ return "#{open}…(#{value.size})#{close}" if depth > INSPECT_MAX_DEPTH || seen.key?(value)
263
+
264
+ seen[value] = true
265
+ begin
266
+ parts = value.first(INSPECT_MAX_ITEMS).map(&render)
267
+ parts << "…(+#{value.size - INSPECT_MAX_ITEMS} more)" if value.size > INSPECT_MAX_ITEMS
268
+ "#{open}#{parts.join(', ')}#{close}"
269
+ ensure
270
+ seen.delete(value)
271
+ end
272
+ end
273
+
274
+ # Rendered by hand rather than via Hash#inspect, whose format differs
275
+ # between Ruby 3.3 (`{:a=>1}`) and 3.4 (`{a: 1}`).
276
+ def inspect_hash_key(key, depth, seen)
277
+ return "#{key.name}: " if key.is_a?(Symbol) && key.inspect.match?(/\A:\w+[?!]?\z/)
278
+
279
+ "#{inspect_bounded(key, depth, seen)} => "
280
+ end
281
+
282
+ def inspect_truncated(string)
283
+ return string.inspect if string.length <= INSPECT_MAX_STRING
284
+
285
+ "#{string[0, INSPECT_MAX_STRING].inspect}…(+#{string.length - INSPECT_MAX_STRING} chars)"
286
+ end
287
+
288
+ # Callbacks (can_use_tool, hooks, callback_wrapper, ...) are user-supplied:
289
+ # render them from source_location rather than their own #inspect, which
290
+ # a subclass may override (and raise from) and which embeds an absolute
291
+ # path — `#<Proc(lambda) permissions.rb:17>`, `#<Method Policy#call>`.
292
+ def inspect_callable(value)
293
+ label = if value.is_a?(Proc)
294
+ value.lambda? ? 'Proc(lambda)' : 'Proc'
295
+ else
296
+ "#{value.class.name} #{value.owner.name || value.owner.inspect}##{value.name}"
297
+ end
298
+ file, line = value.source_location
299
+ rendered = file ? "#<#{label} #{File.basename(file)}:#{line}>" : "#<#{label}>"
300
+ inspect_truncated_text(rendered)
301
+ rescue StandardError
302
+ inspect_leaf(value)
303
+ end
304
+
305
+ def inspect_truncated_text(rendered)
306
+ return rendered if rendered.length <= INSPECT_MAX_STRING
307
+
308
+ "#{rendered[0, INSPECT_MAX_STRING]}…(+#{rendered.length - INSPECT_MAX_STRING} chars)"
309
+ end
310
+
311
+ # Printing must never raise (it runs inside loggers and `puts`), so an
312
+ # object whose #inspect raises, or a BasicObject without one, falls back
313
+ # to a placeholder.
314
+ def inspect_leaf(value)
315
+ return "#<#{value.class}>" if kernel_inspect_only?(value)
316
+
317
+ inspect_truncated_text(value.inspect)
318
+ rescue StandardError
319
+ begin
320
+ "#<#{value.class}>"
321
+ rescue StandardError
322
+ '#<?>'
323
+ end
324
+ end
325
+
326
+ def kernel_inspect_only?(value)
327
+ Kernel.instance_method(:method).bind_call(value, :inspect).owner == Kernel
328
+ rescue TypeError # not a Kernel object: BasicObject, Delegator
329
+ false
330
+ end
331
+
162
332
  # Allow camelCase attribute access
163
333
  def method_missing(method_name, ...)
164
334
  normalized = normalize_name(method_name)
@@ -230,6 +400,10 @@ module ClaudeAgentSDK
230
400
  # Text content block
231
401
  class TextBlock < Type
232
402
  attr_accessor :text
403
+
404
+ def to_s
405
+ text.to_s
406
+ end
233
407
  end
234
408
 
235
409
  # Thinking content block
@@ -375,6 +549,22 @@ module ClaudeAgentSDK
375
549
  super
376
550
  @data ||= attributes if attributes.is_a?(Hash)
377
551
  end
552
+
553
+ def to_s
554
+ subtype.nil? ? '[system]' : "[system: #{subtype}]"
555
+ end
556
+
557
+ private
558
+
559
+ # A typed subclass (InitMessage, TaskStartedMessage, ...) already exposes
560
+ # the fields of its raw frame as attributes; repeating @data would double
561
+ # the output. A bare SystemMessage (unrecognized subtype) keeps it, since
562
+ # @data is the only place its payload lives.
563
+ def inspect_attributes
564
+ return super if instance_of?(SystemMessage)
565
+
566
+ super.reject { |pair| pair.first == 'data' }
567
+ end
378
568
  end
379
569
 
380
570
  # Init system message (emitted at session start and after /clear)
@@ -755,6 +945,19 @@ module ClaudeAgentSDK
755
945
  # @return [Hash{Symbol => Object}, nil]
756
946
  # @see UserMessage#origin
757
947
  attr_accessor :origin
948
+
949
+ # One human-readable line, e.g. `[result: success, 3 turns, 4.2s, $0.0120]`
950
+ # (parts the CLI did not report are left out). An error result appends its
951
+ # `errors`. Use #inspect for every field.
952
+ def to_s
953
+ parts = [subtype].compact
954
+ parts << "#{num_turns} #{num_turns == 1 ? 'turn' : 'turns'}" unless num_turns.nil?
955
+ parts << format('%.1fs', duration_ms / 1000.0) if duration_ms.is_a?(Numeric)
956
+ parts << format('$%.4f', total_cost_usd) if total_cost_usd.is_a?(Numeric)
957
+ line = parts.empty? ? '[result]' : "[result: #{parts.join(', ')}]"
958
+ line += " - #{Array(errors).join('; ')}" if is_error && !Array(errors).empty?
959
+ line
960
+ end
758
961
  end
759
962
 
760
963
  # Stream event for partial message updates
@@ -1723,6 +1926,8 @@ module ClaudeAgentSDK
1723
1926
  attr_accessor :command, :args, :env
1724
1927
  attr_reader :type
1725
1928
 
1929
+ inspect_filtered :env
1930
+
1726
1931
  def initialize(attributes = {})
1727
1932
  super
1728
1933
  @type = 'stdio'
@@ -1742,6 +1947,8 @@ module ClaudeAgentSDK
1742
1947
  attr_accessor :url, :headers
1743
1948
  attr_reader :type
1744
1949
 
1950
+ inspect_filtered :headers
1951
+
1745
1952
  def initialize(attributes = {})
1746
1953
  super
1747
1954
  @type = 'sse'
@@ -1760,6 +1967,8 @@ module ClaudeAgentSDK
1760
1967
  attr_accessor :url, :headers
1761
1968
  attr_reader :type
1762
1969
 
1970
+ inspect_filtered :headers
1971
+
1763
1972
  def initialize(attributes = {})
1764
1973
  super
1765
1974
  @type = 'http'
@@ -1980,6 +2189,9 @@ module ClaudeAgentSDK
1980
2189
 
1981
2190
  # Claude Agent Options for configuring queries
1982
2191
  class ClaudeAgentOptions < Type
2192
+ # `env` routinely carries credentials (ANTHROPIC_API_KEY, ...).
2193
+ inspect_filtered :env
2194
+
1983
2195
  attr_accessor :allowed_tools, :system_prompt, :mcp_servers, :permission_mode,
1984
2196
  :resume, :resume_session_at, :session_id, :max_turns, :disallowed_tools,
1985
2197
  :model, :permission_prompt_tool_name, :cwd, :cli_path, :settings,
@@ -2226,13 +2438,17 @@ module ClaudeAgentSDK
2226
2438
  # A callable receiving a zero-arg invocation; it MUST call it and
2227
2439
  # return its value:
2228
2440
  #
2229
- # callback_wrapper: ->(invocation) { Rails.application.executor.wrap { invocation.call } }
2441
+ # callback_wrapper: ->(invocation) { MyApm.trace('agent.callback') { invocation.call } }
2230
2442
  #
2231
2443
  # The wrapper runs on the same execution context as the callback —
2232
- # inside the worker thread in :thread mode (so executor.wrap checks AR
2233
- # connections back in when the callback ends), in place on the reactor
2444
+ # inside the worker thread in :thread mode, in place on the reactor
2234
2445
  # fiber in :inline mode. Exceptions propagate through it unchanged; it
2235
2446
  # must not swallow them. Default nil (no wrapping).
2447
+ #
2448
+ # Rails apps: use ClaudeAgentSDK::Railtie.callback_wrapper, which runs
2449
+ # callbacks in the Rails executor (AR connections check back in when the
2450
+ # callback ends). A bare `Rails.application.executor.wrap` deadlocks
2451
+ # under development code reloading in :thread mode.
2236
2452
  def callback_wrapper=(value)
2237
2453
  raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})" unless value.nil? || value.respond_to?(:call)
2238
2454
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.34.0'
4
+ VERSION = '0.35.0'
5
5
  end
@@ -20,6 +20,10 @@ require_relative 'claude_agent_sdk/session_resume'
20
20
  require_relative 'claude_agent_sdk/session_mutations'
21
21
  require_relative 'claude_agent_sdk/fiber_boundary'
22
22
  require_relative 'claude_agent_sdk/option_warnings'
23
+ # Rails apps only: Bundler.require runs after `require 'rails'`, so the
24
+ # Railtie (rake tasks; the generator lives under lib/generators) is picked up
25
+ # there and nowhere else.
26
+ require_relative 'claude_agent_sdk/railtie' if defined?(Rails::Railtie)
23
27
  require 'async'
24
28
  require 'securerandom'
25
29
 
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module ClaudeAgentSDK
6
+ module Generators
7
+ # `bin/rails generate claude_agent_sdk:install` — writes the initializer,
8
+ # ignores the vendored CLI, and prints the next steps. Lives under
9
+ # lib/generators/ so Rails' generator lookup finds it by namespace.
10
+ class InstallGenerator < ::Rails::Generators::Base
11
+ # Explicit: Thor derives the namespace by snake-casing the class path,
12
+ # which turns ClaudeAgentSDK into "claude_agent_s_d_k".
13
+ namespace 'claude_agent_sdk:install'
14
+ source_root File.expand_path('templates', __dir__)
15
+
16
+ desc 'Creates config/initializers/claude_agent_sdk.rb and git-ignores the vendored Claude Code CLI.'
17
+
18
+ GITIGNORE_ENTRY = '/vendor/claude/'
19
+ # Spellings that already ignore the vendored CLI directory.
20
+ GITIGNORE_PATTERN = %r{\A/?vendor/claude/?\z}
21
+
22
+ def create_initializer
23
+ template 'claude_agent_sdk.rb.tt', 'config/initializers/claude_agent_sdk.rb'
24
+ end
25
+
26
+ def ignore_vendored_cli
27
+ path = File.join(destination_root, '.gitignore')
28
+ entry = "# Claude Code CLI vendored by `bin/rails claude_agent_sdk:install_cli`\n#{GITIGNORE_ENTRY}\n"
29
+ return create_file('.gitignore', entry) unless File.exist?(path)
30
+
31
+ content = File.read(path)
32
+ if content.each_line.any? { |line| line.strip.match?(GITIGNORE_PATTERN) }
33
+ say_status :identical, '.gitignore (already ignores vendor/claude)', :blue
34
+ else
35
+ append_to_file '.gitignore', gitignore_separator(content) + entry
36
+ end
37
+ end
38
+
39
+ def show_next_steps
40
+ say <<~MSG
41
+
42
+ Next steps:
43
+ 1. Install the Claude Code CLI this gem is tested with into vendor/claude
44
+ (run it in your Dockerfile / bin/setup as well):
45
+ bin/rails claude_agent_sdk:install_cli
46
+ 2. Provide credentials to the CLI, e.g. ANTHROPIC_API_KEY in the environment.
47
+ 3. Review config/initializers/claude_agent_sdk.rb.
48
+
49
+ Rails guide: https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/rails.md
50
+ MSG
51
+ end
52
+
53
+ private
54
+
55
+ # Start the appended block on its own line, after a blank one.
56
+ def gitignore_separator(content)
57
+ return '' if content.empty?
58
+
59
+ content.end_with?("\n") ? "\n" : "\n\n"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Claude Agent SDK defaults, merged into every ClaudeAgentSDK.query and
4
+ # ClaudeAgentSDK::Client session; per-call ClaudeAgentOptions override them.
5
+ # Guide: https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/rails.md
6
+
7
+ # Uncomment together with `observers:` below (needs the opentelemetry-sdk gem).
8
+ # require 'claude_agent_sdk/instrumentation'
9
+
10
+ ClaudeAgentSDK.configure do |config|
11
+ config.default_options = {
12
+ # Model: full ID or alias ('opus', 'sonnet', 'haiku'). Unset, the CLI picks.
13
+ # model: 'claude-sonnet-5',
14
+ # model: 'claude-opus-5',
15
+ # model: 'claude-haiku-4-5',
16
+
17
+ # Tool permissions: 'default', 'acceptEdits', 'plan', 'dontAsk',
18
+ # 'bypassPermissions' (unattended jobs in a sandbox only), or 'auto'.
19
+ # permission_mode: 'default',
20
+
21
+ # The CLI is found in vendor/claude relative to the process cwd (Rails.root
22
+ # under bin/rails, Puma and most job runners). Pin it for other launchers:
23
+ # cli_path: Rails.root.join('vendor', 'claude', 'claude').to_s,
24
+
25
+ # OpenTelemetry tracing (Langfuse, Honeycomb, ...). A factory lambda gives
26
+ # every query/session its own observer — safe under Puma and job threads.
27
+ # observers: [-> { ClaudeAgentSDK::Instrumentation::OTelObserver.new }],
28
+
29
+ # Wraps every SDK callback (tool handlers, hooks, permission callbacks,
30
+ # message blocks, observers) so ActiveRecord connections go back to the
31
+ # pool. Not a bare `Rails.application.executor.wrap`: that deadlocks with
32
+ # development code reloading. See docs/rails.md ("callback_wrapper").
33
+ callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper
34
+ }
35
+ end
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.34.0
4
+ version: 0.35.0
5
5
  platform: ruby
6
6
  authors:
7
- - Community Contributors
7
+ - ya-luotao
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
@@ -106,9 +106,14 @@ dependencies:
106
106
  - - "~>"
107
107
  - !ruby/object:Gem::Version
108
108
  version: 1.87.0
109
- description: Unofficial Ruby SDK for interacting with Claude Code, supporting bidirectional
110
- conversations, custom tools, and hooks. Not officially maintained by Anthropic.
111
- email: []
109
+ description: 'Unofficial Ruby SDK for the Claude Code agent runtime: one-shot queries
110
+ and bidirectional sessions, in-process custom tools, hooks and permission callbacks.
111
+ Includes Rails integration (Railtie, install generator, CLI-vendoring rake task,
112
+ executor-aware callback wrapper), a pinned CLI installer, OpenTelemetry tracing,
113
+ and session transcript mirroring. Not affiliated with or officially maintained by
114
+ Anthropic.'
115
+ email:
116
+ - luotao@hey.com
112
117
  executables: []
113
118
  extensions: []
114
119
  extra_rdoc_files: []
@@ -141,6 +146,7 @@ files:
141
146
  - lib/claude_agent_sdk/observer.rb
142
147
  - lib/claude_agent_sdk/option_warnings.rb
143
148
  - lib/claude_agent_sdk/query.rb
149
+ - lib/claude_agent_sdk/railtie.rb
144
150
  - lib/claude_agent_sdk/sdk_mcp_server.rb
145
151
  - lib/claude_agent_sdk/session_mutations.rb
146
152
  - lib/claude_agent_sdk/session_resume.rb
@@ -149,11 +155,15 @@ files:
149
155
  - lib/claude_agent_sdk/sessions.rb
150
156
  - lib/claude_agent_sdk/streaming.rb
151
157
  - lib/claude_agent_sdk/subprocess_cli_transport.rb
158
+ - lib/claude_agent_sdk/tasks.rb
159
+ - lib/claude_agent_sdk/tasks/claude_agent_sdk.rake
152
160
  - lib/claude_agent_sdk/testing/session_store_conformance.rb
153
161
  - lib/claude_agent_sdk/transcript_mirror_batcher.rb
154
162
  - lib/claude_agent_sdk/transport.rb
155
163
  - lib/claude_agent_sdk/types.rb
156
164
  - lib/claude_agent_sdk/version.rb
165
+ - lib/generators/claude_agent_sdk/install/install_generator.rb
166
+ - lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt
157
167
  homepage: https://github.com/ya-luotao/claude-agent-sdk-ruby
158
168
  licenses:
159
169
  - MIT
@@ -181,5 +191,5 @@ requirements: []
181
191
  rubygems_version: 3.4.19
182
192
  signing_key:
183
193
  specification_version: 4
184
- summary: Unofficial Ruby SDK for Claude Agent
194
+ summary: Unofficial Ruby SDK for Claude Agent, with Rails integration
185
195
  test_files: []