Repository navigation
v0.18.0
Full-SDK audit release (2026-06-12): six batches of correctness, Python-parity, and robustness fixes spanning teardown/liveness, the control protocol, observer/OTel instrumentation, the SDK MCP server, session readers, and the subprocess transport. 987 specs green.
Added
query(transport:)— inject a pre-constructed custom transport instance (Python parity): CLI discovery, version check, and resume materialization are skipped; the SDK calls#connect/#closeon the instance (including after a failed connect — a documented safety deviation from Python).Client#querynow accepts an Enumerable of message Hashes / JSONL Strings in addition to a String (Python parity): items stream inline on the caller,session_idis stamped onto Hashes that lack one (explicit values, even nil, preserved), and non-Hash/String items raise instead of being silently serialized. Note: JSONL String items pass through verbatim and carry their ownsession_id— generate them with the matchingsession_id:argument (Streaming.user_messagedefaults to'default').Client.open(prompt = nil, options:, ...) { |client| }— block-scoped lifecycle mirroring Python'sasync with ClaudeSDKClient(): connects, yields, always disconnects; works standalone (creates a reactor viaSync) and insideAsyncblocks; returns the block's value.ClaudeAgentOptions#useris now applied — the CLI subprocess is spawned as that OS user via spawn's:uid(String username or Integer uid, Unix; previously accepted but silently ignored). On unsupported platforms the failure is loud (CLIConnectionError), not silent.HookMatcher#timeoutis now sent to the CLI in the initialize request (per matcher, seconds — Python wire parity); the SDK-side enforcement remains as defense-in-depth.ClaudeAgentSDK.list_subagents/ClaudeAgentSDK.get_subagent_messages— local-disk subagent transcript readers (disk counterparts to the existing*_from_storevariants; Python SDK parity, upstream #825). Scans<projectDir>/<sessionId>/subagents/**/agent-<id>.jsonlincluding nestedworkflows/<runId>/paths. Note:limit: 0returns[]per the Ruby read-API family convention (Python returns all).- W3C trace-context propagation to the CLI subprocess (Python SDK #821 parity): when
opentelemetryis loaded and a span is active at connect,TRACEPARENT/TRACESTATE(and any other propagator carrier keys such asBAGGAGE, uppercased) are injected into the CLI env so CLI-side OTel spans join the caller's distributed trace. ExplicitClaudeAgentOptions#envkeys win; stale inherited W3C env is scrubbed only when an active span replaces it. No new dependency — no-op without the opentelemetry gem. CLAUDE_AGENT_SDK_SKIP_VERSION_CHECKenv var skips the CLI version check (any non-empty value, Python parity), and the check itself now has a 2-second deadline — a hungclaude -v(NFS-mounted binary, wedged Node bootstrap) no longer hangsconnectforever. The unsupported-version warning now includes the CLI path.ClaudeAgentOptions#skills(Python parity): enable skills for the main session with'all'or an Array of names. InjectsSkill/Skill(name)into--allowedTools, defaultssetting_sourcesto['user', 'project']when unset, and sends explicit lists via theinitializecontrol request so the CLI filters which skills load.[]hides all skills; this is a context filter, not a sandbox.- OTel spans now report prompt-cache usage:
gen_ai.usage.cache_creation_input_tokens/gen_ai.usage.cache_read_input_tokenson generation and session spans, plus OpenInferencellm.token_count.prompt_details.cache_read/.cache_writeon the session span. Anthropic'sinput_tokensexcludes cached tokens, so heavily cached sessions previously under-reported prompt volume by orders of magnitude. Strictly additive — existing keys unchanged. on_user_promptobservers now fire for Enumerator/streaming-input prompts (once pertype: 'user'message with extractable text). OTel traces for streaming sessions now get aninput.valuefor the first trace; later turns' capture depends on prompt timing relative to each init (OTelObserverkeeps one prompt per trace).
Fixed
SubprocessCLITransport#end_inputnow takes the stdin mutex like write/close (its lock-free close could race a concurrent writer into a misleading "undefined method ... for nil" error that also poisoned@exit_error).CLAUDE_CODE_ENTRYPOINTnow defaults tosdk-rbregardless of inherited process env (an ambientclivalue from running inside a Claude Code terminal previously won via||=, mis-attributing telemetry);options.envcan still override it.CLAUDE_AGENT_SDK_VERSIONis now always SDK-set, never overridable (Python merge-order parity).- Unknown hook events now arrive as
UnknownHookInputcarrying the wirehook_event_nameand the complete raw payload (previously all event-specific fields were dropped and the name was nil; Python passes the raw dict through, losing nothing). - Oversized CLI stdout lines no longer allocate unbounded memory: the read loop's 1MB buffer cap previously fired only AFTER
each_linehad read the whole line into memory; reads are now chunk-bounded atmax_buffer_size + 1bytes (Python's TextReceiveStream reads ≤64KB chunks — same incremental-cap semantics). The same bound applies to the stderr drain loops. advisor_tool_resultcontent blocks now parse intoServerToolResultBlock(they previously fell through toUnknownBlock); theserver_tool_resultwire type was dead code — no CLI version emits it — and now takes the forward-compatUnknownBlockpath.- README's Client quick-start example used
receive_messageswith no termination and hung forever when pasted; it now usesreceive_response. Configuration#default_optionscontainers are now deep-duplicated when constructingClaudeAgentOptions:options.allowed_tools << 'Bash'in one session no longer mutates the global default (cross-session permission widening) and nested default hashes/arrays no longer leak mutations. Leaf objects (callbacks, observer factories, SDK MCP server instances) keep identity.- Hash-form
thinkingconfig ({ type: 'adaptive'|'enabled'|'disabled', budget_tokens:, display: }) is now serialized to the CLI; it was previously dropped silently and also suppressed themax_thinking_tokensfallback. Invalid shapes raise a clearArgumentError. - Control-protocol client methods (
interrupt,set_model, …) can now be called from inside hook/can_use_tool/SDK-MCP callbacks (Python reentrancy parity): the call previously wrote the request to the CLI and then crashed with an opaqueRuntimeError: No async task available!, silently dropping the response. Worker-thread callers now wait on a level-triggered queue with the same timeout semantics. - Closed a lost-wakeup race in control-request waiting:
Async::Conditionis edge-triggered, so acontrol_responsearriving while the sender was still suspended in a transport write was dropped and the caller waited the full 1200s timeout (reachable with custom transports whose#writesuspends after delivery, and via the read-loop error broadcast). Senders now check the result slot before and between waits (anyio.Event level-trigger semantics, like Python). SdkMcpServer#handle_jsonnow actually serves resources and prompts:resources/listcrashed inside the mcp gem (Class#to_h),resources/readsilently returned{contents: []}for every URI (the read path referencedMCP::ResourceContents, a constant that has never existed in any mcp gem version),prompts/listmangled names (codeReview→code_review) and dropped descriptions/arguments, andprompts/getcould not find any prompt. Resources are now served asMCP::Resourceinstances with the gem's registered read handler; prompts viaMCP::Prompt.define. Both delegate to the SDK's own readers, preserving the FiberBoundary hop and result validation.- Pre-built JSON Schemas with symbol values (
type: :object) are no longer silently mangled into garbage (schema meta-keys leaked as parameters, every validtools/callwas then rejected with "Missing required arguments: type, properties, required"). Schema normalization is now a single source of truth;additionalProperties/enum/descriptionsurvive, and a malformed pre-built schema raises a clear error lazily instead of producing silent garbage. list_sessions,get_session_info, andget_session_messagesno longer raiseErrno::ENOENTfor adirectory:that does not exist — they return[]/nil, matching the Python SDK (a fresh checkout or a deleted project directory previously crashed; sessions recorded for a since-deleted directory are now found again).get_session_messagesandimport_session_to_storeno longer stop at a 0-byte transcript stub: the session-file search skips empty files and continues to worktree project dirs, matching Python'sst_size > 0resolver (a stub in the canonical project dir previously hid the real worktree transcript).- An empty
CLAUDE_CONFIG_DIRis now treated as unset (falling back to~/.claude, NFC-normalized) across all session read/mutation paths and SessionStore mirroring, matching the Node CLI and Python SDK — previously the projects dir resolved to/projectsand transcript mirroring was silently disabled. list_sessions/get_session_infono longer returncreated_at: nilwhen a transcript's first JSONL record is a metadata-only entry (e.g.permission-mode) with notimestampfield — the whole 64 KiB head window is now scanned for the first timestamp (Python #907 parity). The store-backed readers already folded every entry, so the disk and store paths now agree oncreated_at.Observer#on_erroris now actually invoked — once per error surfacing fromquery()orClient#query/#receive_messages/#receive_response/#connect, beforeon_closewhere both fire. Crashed sessions now produce OTel traces with error status and a recorded exception.OTelObserverno longer mislabels traces when one instance is reused: the buffered prompt/output are reset at every trace boundary (previously every trace after the first showed the first query's prompt as itsinput.value, and a nil-result trace could leak the previous query's output).OTelObserverno longer leaks unfinished (never-exported) spans: a newInitMessagewithout an interveningResultMessagefinishes the superseded root span, and tool spans still pending at aResultMessageor superseded init are finished at that boundary instead of waiting foron_close.- OTel tool spans now serialize Array tool-result content as JSON with
output.mime_type: application/json(previously Rubyinspectformat); String results gainoutput.mime_type: text/plain; nil results omitoutput.value(previously empty string). breakinside the user block ofquery(),Client#receive_messagesandClient#receive_responsenow stops iteration (returning the break value) instead of raisingLocalJumpError— the FiberBoundary thread hop translates the break back to the calling fiber (new internalFiberBoundary.invoke_iteration).- CLI stdout/stderr are now always decoded as UTF-8: under
LANG=C/LC_ALL=C(minimal Docker images, systemd, CI) the pipes inherited US-ASCII and the first non-ASCII byte from the CLI killed the read loop withEncoding::CompatibilityError. Mirrors the Python SDK's UTF-8TextReceiveStream. - The unsupported-CLI-version warning now actually fires:
Array#<does not exist, so the version comparison raisedNoMethodErrorinto the version check's blanket rescue and the warning had never been emitted; the-voutput is also UTF-8-scrubbed so a stray invalid byte cannot suppress it. query()can no longer hang forever when the CLI dies while a streaming-input Enumerator is blocked: the input stream task is tracked on theQuery(newQuery#spawn_task, mirroring the Python SDK's_child_tasks) and stopped byclose, so the real error now propagates to the caller instead of decaying to an async console warning.- A CLI crash now always unblocks in-flight control requests (
interrupt,set_model, …) immediately instead of leaving them to the 1200s control-request timeout, and a real crash in a multi-turn session is reported to consumers instead of being silently swallowed after the first result. - Hooks and SDK MCP servers no longer silently stop working when a one-shot
query()'s first turn runs past 60 seconds: stdin stays open (without timeout) until the first result, mirroring Python SDK commit c3d96cb. String-prompt queries also now stream messages to the block while that wait is pending instead of deferring delivery.
Changed
Client#connect(enumerable)now streams the initial prompt in the background (Python parity): connect returns immediately instead of blocking until the stream is exhausted (interactive streams that wait for a response before yielding no longer deadlock), Hash messages are serialized as JSON (previously Rubyinspectviato_s— never valid wire format), stdin closes when the stream is exhausted, and stream errors fireObserver#on_errorand are logged instead of raising out ofconnect.- SDK MCP
tools/callnow routes through the officialMCP::Server: arguments are JSON-Schema-validated (draft4) against the tool'sinputSchemabefore the handler runs (Python parity — its mcp lowlevel server does the same), and validation failures return in-bandisErrorresults ("Missing required arguments: …"/"Invalid arguments: …") without invoking the handler. Themcpdependency floor rises to>= 0.6, < 1(0.4 turned these into protocol errors; 0.5 serializes empty icons arrays into list responses). The SDK normalizes tools/call error envelopes itself, so the gem's per-version error-behavior swings (0.7.1+/0.18 raise protocol errors again) never leak through. Error texts on this path now come from the gem (e.g. "Tool not found: X", "Internal error calling tool X: msg"). Validation can be disabled globally viaMCP.configure { |c| c.validate_tool_call_arguments = false }. Schemas the draft4 metaschema rejects (numericexclusiveMinimum,$ref— valid modern JSON Schema that Python accepts) fall back to validation-disabled with a one-time warning instead of bricking the tool. - SDK MCP tool failures are now reported in-band (
isError: truewith the error text incontent) instead of as JSON-RPC-32603protocol errors — matching the MCP spec, the Python SDK, and the official mcp gem. The model can now read the error text and self-correct. This covers handler exceptions, unknown tools, and malformed handler results;SdkMcpServer#call_toolno longer raises for these cases. SdkMcpServer#call_tool/#read_resource/#get_promptnow accept string-keyed handler results (Python handlers return string-keyed dicts naturally); previously they rejected them with an error the model saw as a protocol failure.- When an explicit
directory:is given,get_session_messagesandimport_session_to_storeno longer fall back to scanning all project directories: a session that only exists in an unrelated project now returns[]/ raisesErrno::ENOENTinstead of silently returning/importing another project's data under the wrong project key (Python parity;directory: nilstill searches all projects). A 0-byte-stub-only session now raisesErrno::ENOENTfrom import instead of silently importing zero entries. can_use_toolcallbacks now receive fully populatedToolPermissionContexts: the CLI display fields (title,display_name,description,blocked_path,decision_reason) are forwarded (previously always nil), andsuggestionsare typedPermissionUpdateobjects instead of raw wire hashes (Python #920 parity) —PermissionUpdate.newalso hydrates wire-format rule hashes intoPermissionRuleValue. Code treating suggestion entries as plain Hashes (dig,fetch) must use the typed accessors; echoingcontext.suggestionsintoupdated_permissionskeeps working.- A
ProcessErrorthat directly follows a result withis_error: true(the CLI exits non-zero on purpose, e.g. structured-output errors) is now raised with the structured error text the CLI reported (Claude Code returned an error result: …, preservingexit_code/stderr) instead of ending the stream silently — matching the Python SDK. CLAUDE_CODE_STREAM_CLOSE_TIMEOUTis now a no-op (the internalQuery::STREAM_CLOSE_TIMEOUT_*constants were removed with the stdin-close timeout).
Removed
- Breaking:
ClaudeAgentOptions#append_allowed_tools. It emitted--append-allowed-tools, which no Claude Code CLI version accepts (error: unknown option), so any use failed at connect. The option never existed in the Python SDK (mis-port in v0.4.0). Useallowed_toolsinstead — the CLI's--allowedToolsalready appends to settings-derived permission rules. Passingappend_allowed_tools:now raisesArgumentErrorat construction.
Usage
# Block-scoped client lifecycle (Python's `async with` equivalent)
ClaudeAgentSDK::Client.open(options: options) do |client|
client.query('Refactor the parser')
client.receive_response { |msg| handle(msg) }
end
# Enable skills for the main session (context filter)
ClaudeAgentSDK::ClaudeAgentOptions.new(skills: %w[code-review])
# Inject a custom transport into one-shot queries
ClaudeAgentSDK.query(prompt: 'Hi', transport: my_transport) { |msg| }Breaking:
ClaudeAgentOptions#append_allowed_toolshas been removed — it emitted a flag no CLI version accepts, so every use already failed at connect. Useallowed_toolsinstead.
What's changed
- #38 Audit fixes batch 1+2: teardown/liveness, control protocol, observer/OTel, permission context — @ya-luotao
- #39 Audit fixes batches 3-6: parity, sessions, MCP overhaul, medium bugs, final items — @ya-luotao
Full Changelog: v0.17.0...v0.18.0
gem install claude-agent-sdk -v 0.18.0