Repository navigation
v1.2.0
Fixes from a review of the whole SDK, a reference for every option (docs/options.md), and Claude Code 2.1.288 as the pinned CLI. Settings the SDK used to drop without an error now take effect — hook outputs and sandbox: Hashes in Ruby spelling, a tools: String, option Hashes with a Symbol type — so read the entries in bold before upgrading: they say what changes for code that relied on the old behavior.
Added
docs/options.md: a reference for everyClaudeAgentOptionsattribute — its type, its default, and the CLI flag,initializefield or environment variable it becomes — plus what an option reads when it is left out, passed asnilor given a configured default, and the environment variables the SDK reads. It documents options that had no entry anywhere before (extra_args,include_partial_messages,include_hook_events,task_budget,strict_mcp_config) and theCLAUDE_CODE_PRINT_BG_WAIT_CEILING_MSceiling from 1.1.0. A spec checks every row against the code.docs/client.mddocuments thetransport:argument ofquery/ask. (#155)Pathnamewhere a path goes:settings:(a settings file; used to send no--settingsat all),SystemPromptFile#path, thepathof a{ type: 'file' }system prompt Hash, and a plugin path, typed or Hash (these failed at connect withno implicit conversion of Pathname into String).tools:is signedStringas well; the RBS signatures are widened accordingly. (#160)
Changed
docs/cli-installer.mdnow says whenCLIInstaller::PINNED_CLI_VERSIONmoves: pin-only changes ship with the next gem release rather than one release per CLI bump. It also covers how a gem patch can move your CLI and how to take a newer CLI before a release pins it. The pin-bump workflow keeps a single[Unreleased]entry for the pin.CLIInstaller::PINNED_CLI_VERSIONmoves from 2.1.285 to 2.1.288, following the CLI the Python SDK bundles.CLIInstaller.install_pinnedinstalls it. See the Claude Code changelog.- The Rails guide and both Rails examples turn off the CLI's per-project auto-memory (
env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }). Every SDK session reads that memory and aclaude_code-preset session writes it without a permission check, so in a multi-user app one user's "remember …" would reach every other user's session. The generated initializer only documents the switch: it carries the same line commented out, so a newly generated app is not isolated until you uncomment it (or set it per call). If you copied the examples, add the line; the value must be'1'. (#158) docs/configuration.mdhas a "Session Isolation" section. Claude Code's per-project auto-memory is on in SDK sessions: every session loads the project'sMEMORY.mdindex into its context (setting_sources: []does not prevent it), and a session on theclaude_codepreset also writes memory files, which the CLI allows without any permission setup. Servers and multi-tenant hosts should setenv: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }(orsettings: { autoMemoryEnabled: false }); a custom transport has to pass either one through to its CLI itself;Client#context_usage[:memoryFiles]shows what a session loaded. (#155)- A requested sandbox that is not active now produces a warning.
sandbox: { enabled: true }does not guarantee a sandbox: when the CLI cannot start one (a Linux container withoutbubblewrapandsocat, for example) it prints⚠ Sandbox disabled: …to its stderr and runs commands unsandboxed, which a host only saw through astderr:callback. The SDK now repeats that line as a Ruby warning, prefixed[claude-agent-sdk], once per session, when thesandbox:option enabled the sandbox. The default is unchanged; setfail_if_unavailable: trueto make the CLI exit instead. (#156) OTelObservernow leaves a span for a session that fails before its firstInitMessage. A CLI that could not be found or started, or aninitializethat failed or timed out, raised to the caller but exported no span at all (0.18.0's "crashed sessions now produce OTel traces with error status" only held once anInitMessagehad arrived).on_errornow emits aclaude_agent.sessionspan with the observer's default attributes, the prompt if one had been sent, the exception event and error status, and ends it at once. The span has no model orsession.id. Nothing changes once a trace has started. If you countclaude_agent.sessionspans, failed starts are now among them. (#148)docs/observability.mdno longer promises "standardgen_ai.*semantic conventions … any OTel backend". The attributes follow the Langfuse and OpenInference conventions plus a subset of OTelgen_ai.*; the page now lists every attribute, event and statusOTelObserversets, and how thegen_ai.*names differ from the OTel GenAI semantic conventions — notably thatgen_ai.usage.input_tokensleaves cache tokens out, and that the inclusive count isllm.token_count.prompton the session span. No attribute changed. (#148)docs/rails.mdno longer says callbacks inherit the request's state. A callback runs on a thread or fiber Rails never set up:Currentattributes,Time.zone, log tags, the error context and theconnected_torole / shard read as their defaults there (a write underconnected_to(shard:)lands in the default shard), with or withoutRailtie.callback_wrapper;I18n.localedepends on the i18n version. The guide now has the table, a per-call recipe that carries the state across, the rule for composing your own wrapper with the Rails one, a section on transactions and the connection pool (a callback is outside the caller's transaction and uses its own connection), and a note that in development an agent run holds the reload lock. (#158)docs/configuration.mdsays what the CLI does with an invalid sandbox value: it discards the whole--settingsvalue, sandbox andpermissionsrules alike. The SDK does not surface this —connectsucceeds and nothing appears on stderr; only the CLI'sget_settingscontrol response lists the error. That holds for every form, the typed classes included. (#160)- Disk listings (
list_sessions,get_session_info) scan each transcript's head and tail windows as bytes; with non-ASCII text in a window this takes a fraction of the CPU time it did. Results are unchanged. (#157) - Development only: the gemspec's development dependency on Bundler is
>= 2.0(was~> 2.0), sobundle installworks in a checkout on Ruby 4.0, which ships Bundler 4. CI now runs the suite and RuboCop on Ruby 4.0, and a keyless smoke runs the pinned CLI on PRs that touch the installer, the transport,Queryor the message parser; the API-key integration job shows as skipped instead of passed when the key is absent. Runtime dependencies are unchanged. (#153)
Fixed
- A hook callback's Hash return value now means what the equivalent typed output means, whatever its key style. Only five top-level keys were renamed before, and only as Symbols, so
{ hook_specific_output: { hook_event_name: 'PreToolUse', permission_decision: 'deny' } }reached the CLI under keys it does not read: the decision was ignored and the tool ran. A String-keyed Hash (e.g. afterJSON.parse) and aSyncHookJSONOutputholding a snake_case Hash failed the same way. The fields the typed output classes model are now accepted in snake_case or camelCase, as Symbols or Strings, at the top level and insidehook_specific_output; any other key is sent as written, and the keys insideupdated_input, the tool outputs and aPermissionRequestdecisionare not renamed. If a hook of yours returns snake_case keys, they now take effect. When one Hash spells a field both ways the CLI's spelling is sent, and a Symbol and a String spelling the same key are written once (json 3.x used to fail the hook withdetected duplicate key). (#149) - A
sandbox:Hash written in Ruby spelling is now applied. The typed sandbox classes take snake_case attributes and write the camelCase keys the CLI reads, but a Hash was forwarded as written:sandbox: { enabled: true, network: { denied_domains: ['evil.example'] }, filesystem: { deny_read: ['~/.aws'] } }reached the CLI under keys it does not know, and the CLI ignored them without an error — the session ran withnetwork: {}andfilesystem: {}, no deny rule.SandboxSettings.new(network: { denied_domains: [...] })lost its rules the same way. The fields ofSandboxSettings,SandboxNetworkConfigandSandboxFilesystemConfigare now accepted in snake_case or camelCase, as Symbols or Strings, at the top level and insidenetwork/filesystem; any other key is sent as written. If a sandbox Hash of yours has snake_case keys, they now take effect. A snake_case key is renamed only when its value has the shape the CLI accepts for it (true/false, an Array of Strings, a port number from 0 to 65535); with any other value it is sent as written and stays without effect, as before — so the rename can never make the CLI discard your settings. When one Hash spells a field both ways the camelCase one is sent; a field holdingnilis left out (the CLI rejectsnullthere); a Symbol and a String spelling the same key are written once. A typedSandboxNetworkConfig/SandboxFilesystemConfiginside asandbox:Hash is written as the Hash it stands for (it reached the CLI as its#inspecttext, which made the CLI drop the sandbox). (#160) - "Always allow" answers from
can_use_toolnow stick for whole-tool rules.PermissionUpdate#to_hwrote"ruleContent": nullfor a rule without content and omitted a nildestination; the CLI accepts neither and drops the wholeupdatedPermissionsarray when one entry is malformed, soPermissionResultAllow.new(updated_permissions: context.suggestions)silently persisted nothing for the suggestions the CLI builds forWebSearchor MCP tools, and the callback was asked again on every call.ruleContentis now omitted when nil, and an update that has atypebut nodestinationis sent to'session'(this run only; no settings file is written). (#149) tools: 'Read,Grep'is no longer ignored. A String had no branch in the command builder, so no--toolsflag was sent and the session got every built-in tool. It is now passed to--toolsas written (the CLI's own comma-separated form). If you passtools:as a String, the session is now restricted to the tools it names. (#160)system_prompt,tools,output_formatandpluginsHashes accept a Symboltype, likethinkingand the MCP server configs already did.tools: { type: :preset, preset: :claude_code }sent the Hash's JSON text as a tool name and the session started with no tools;system_prompt: { type: :custom, prompt: '…' }(and:file,:preset) sent no flag, so the prompt and its options were dropped;output_format: { type: :json_schema, schema: {…} }made the CLI exit;plugins: [{ type: :local, … }]raisedUnsupported plugin type. Anoutput_formatHash withtypeandschemaunder different key styles silently turned structured output off;schemais now found under either. A Symbol-typed Hash means what the String-typed one means, including its check:{ type: :custom }without a:promptString raises the sameArgumentError. (#160)- Directory-scoped session APIs could read and modify another project's sessions. For project paths over 200 characters,
list_sessions(directory: B),get_session_messagesandrename_session/tag_session/delete_session/fork_sessionfell back to ANY project directory sharing the first 200 characters, so they could return or change the sessions of a sibling path A. In a directory found by that fallback, only sessions whose own transcript records the requested path as its top-levelcwdare listed, read or modified (worktrees whose paths share such a directory are each listed). (#157) - A relative CLI path could run a different binary than the one the SDK checked. The version probe runs in the process's working directory but the CLI is spawned in
cwd:, socli_path: 'bin/claude', or aclaudefound through a relativePATHentry (bin,., an empty entry), was checked in one directory and executed in the other — whatever file sat at that path inside the directory the agent was pointed at. The transport now settles one absolute path and uses it for both: a relativecli_pathis resolved against the process's working directory (a leading~is expanded), and a bare name is looked up onPATHby the SDK, with relativePATHentries resolved against the process's working directory. Discovery no longer runswhich: the SDK searches the process'sPATHforclaudeitself, so awhichfound through a relativePATHentry can neither run nor redirect the lookup (a host without awhichprogram now findsclaudeonPATH; with noPATHat all, discovery searches the system directories, never the working directory). If you relied on a relativecli_pathresolving insidecwd:, pass an absolute path. A subclass whose#build_commandruns the CLI through another program (docker exec, ssh) still getscli_pathas given, since it names a file on the far side. (#156) #inspectno longer prints credentials held outsideenvandheaders.ClaudeAgentOptions#inspect(and#to_s,pp, string interpolation) now filterssettingsandextra_argsthe way it filtersenv: a Hash keeps its keys with"[FILTERED]"values, and a Stringsettings(JSON or a file path) prints as"[FILTERED]".mcp_serversgiven as a String (JSON or a file path) prints as"[FILTERED]"too.McpStdioServerConfig#argsis filtered. TheurlofMcpHttpServerConfig/McpSSEServerConfigprints as scheme, host and port only ("https://mcp.example.com/[FILTERED]"), since a token can sit in its userinfo, path or query. Raw Hash server configs are filtered the same way (type,commandand the redactedurlare shown;args,env,headersand any other key print as"[FILTERED]"): insidemcp_servers, where they used to rely on the nesting limit, and inMcpServerStatus#config, where the CLI echoes each server'sheaders,urlandargsback inmcp_status. Display only: the objects are not modified and nothing sent to the CLI changes. (#151)- A materialized resume directory preserved after a mirror failure now holds only
projects/. The cleanup removed four known files and left whatever else the CLI had written — includingbackups/.claude.json.backup.<ts>, a full copy of the seeded.claude.json, which can hold MCP header secrets. Every entry exceptprojects/is now deleted. To do that safely the SDK first moves the directory into a fresh private one next to it (claude-preserved-resume-*), so the preserved transcript is no longer at the temp dir's old path: the warning names the new one. If what it moved is not the directory it created (the path had been replaced by a symlink or another directory), or it cannot be moved, nothing is deleted and the warning says the scrub was skipped and why; symlinks are never followed, permissions are repaired only on the directory that was checked, an entry that cannot be removed is named in the warning, and an interrupted scrub says which directory to remove once the transcript is imported. Callingdisconnectagain after a teardown that was interrupted while it preserved the directory no longer deletes it. (#159) ClaudeAgentSDK::Railtie.callback_wrapperno longer reports toRails.error. In production it ran callbacks insideexecutor.wrap, which reports whatever passes through it as an unhandled error: a message-block exception was reported from the callback thread with an empty context (and then skipped by the request / job layer as already reported), exceptions the SDK handles itself (hook,can_use_tool, SDK MCP tool, observer) were reported although the run continued, and — on Rails 7.2.3, 8.0.2 and later — so were the SDK's own cancellations undercallback_scheduling: :inline. The wrapper now enters the executor withrun!/complete!(same hooks, no report), so an exception that escapes a callback is reported once, by the layer that called the SDK, with its controller / job context. If you relied on those reports to see hook or tool failures, report them inside the callback (Rails.error.report). (#158)- A callback that fails with
NotImplementedError,LoadError,SystemStackErrororSecurityErroris answered like any other failing callback. These are notStandardErrors, so nothing rescued them when a hook, acan_use_toolcallback, an SDK MCP tool, resource or prompt handler, or thecallback_wrapperraised one: the CLI never got a response for that request, and the exception stopped the whole Async reactor, taking every other session and task on it down (query()raised the raw exception). Now hooks andcan_use_toolget the error response, a tool handler's failure reaches the model as anisError: trueresult, and a resource or prompt handler gets the JSON-RPC-32603error, exactly as for aRuntimeError; the session carries on.SdkMcpServer#call_toolcalled directly returns theisErrorresult for these exceptions instead of raising.exit,Interrupt, signals and cancellation behave as before, andNoMemoryErroris still fatal. (#150) - A callback failure whose message is not valid UTF-8 is answered. When a hook or
can_use_toolcallback raised an exception whose message held invalid bytes (a multibyte character cut bybyteslice, binary subprocess or HTTP output), building the error response raisedJSON::GeneratorErrorand the CLI was never answered. On the process-exit path the same error replaced theSystemExitthe SDK promises to re-raise: under:inlineschedulingexitdid not end the process. Anexitor signal exception whose message was in an encoding that is not ASCII-compatible (UTF-16) failed the same way, withEncoding::CompatibilityError. The error text is now normalized first — invalid bytes become U+FFFD, text in another valid encoding is transcoded — and a response that still cannot be serialized is replaced by a fixed message, so the request is always answered and the original exception is the one re-raised. (#150) query()with a streamed (Enumerator) prompt no longer closes stdin right after writing a message. With hooks,can_use_toolor SDK MCP servers, stdin could close before that message's turn had produced a frame, so the turn's hook, permission and SDK MCP requests failed with "Stream closed". It happened when the CLI'sidlefor the previous run and the next message became ready in the same reactor pass:Query#end_runstopped the between-turns ceiling task (which yields) before it marked the run ended, and the message joined the run that was about to end. The run is now marked ended first. (#154)- A
Clientcontrol method called from a fiber on another thread's reactor no longer breaks the session.interrupt,set_model,set_permission_mode,mcp_status, … called throughSync { client.interrupt }on a request thread while the session runs on a background reactor, or from aSyncblock inside a:thread-mode callback: on async 2.10–2.28 the response raisedFiberError: fiber called across threadsin the session's read loop, which ended the session and left the caller waiting for the control-request timeout (20 minutes by default); on async ≥ 2.29 the response could be missed, with the same wait. Such callers now wait the way plain threads already did. (#154) receive_response/receive_messagesno longer hang after the CLI is gone. After the CLI exited or crashed while aClientwas connected, the first call raised the stream error (or returned the last turn), the second returned without messages, and every later call hung forever with no error and no timeout (from plain Ruby after the reactor had finished, async 2.10 raisedNoMethodErrorinstead). Every call after the end of the stream now returns at once; the stream error is still raised once. (#154)- A deadline around a
Clientcall is no longer swallowed while an observer runs. With observers configured, a deadline aroundClient#receive_response,#receive_messages,#queryor#disconnect(task.with_timeout, orTimeout.timeoutinside a reactor) was silently dropped when it expired while an observer callback was still running: nothing was raised and the turn carried on, calling the message block after the deadline. Only what the observer (or itscallback_wrapper) raises is contained now. Withcallback_scheduling: :inlinethe observer runs on the caller's own fiber, where such a deadline cannot be told from the observer's own failure, and it is still contained. (#154) Client#disconnect, and the cleanup of a failingClient#connect, always tear the session down. When the call was interrupted while an observer'son_close/on_errorwas running — the caller's task stopped (Async::Stop), or an inline hook's cooperative timeout fired — the CLI process, the session's read loop and the materialized resume directory (with its credentials copy) were left behind. The teardown now always runs, and the interruption propagates after it. (#154)- A
Client#disconnectfrom another thread completes the CLI's graceful shutdown. From an application thread, or from a tool handler, hook orcan_use_toolcallback under the defaultcallback_scheduling: :thread, the close was handed to a transient task on the session's reactor; when the session had nothing else left to do the reactor wound down and stopped that task mid-teardown, so the CLI got SIGTERM instead of stdin EOF and its 5-second grace period (the time it has to finish its last session write), anddisconnectreturned before the child was reaped. The close now runs on a task the reactor waits for: such adisconnecttakes as long as the teardown, like one made on the reactor. (#161) - A callback's thread hop no longer returns before the callback has finished. When an exception (
Async::Stop, a timeout) had been raised into the waiting fiber just as the previous hop's thread ended, the stale wakeup resumed the next hop, which returnednilas its result; with atimeout:(session-store adapter calls) the same wakeup was reported astimed outalthough no time had passed.FiberBoundary.invokenow waits until the thread has really finished or the deadline has really passed. (#154) - An error raised by
ClaudeAgentSDK.query/.askfrom synchronous code (CLI not found,ProcessError,ResultError, an exception in your own block) was also logged by Async asTask may have ended with unhandled exception., with the message and a backtrace, even when your code rescued it. The error is now only raised. Inside a reactor the same applied to failures before the first suspension, such as a transport that cannot connect. (#154) - A
stderr:callback that raisedNotImplementedError,LoadErrororSystemStackErrorcould hang the session. OnlyStandardErrorwas contained, so those ended the thread that reads the CLI's stderr; once the CLI had written another 64 KiB of stderr it blocked for good. They are now handled like any other callback error: dropped, and the callback is called again for the next line. The same holds for adebug_stderr:sink and for the SDK's own sandbox warning when a customWarning.warnor$stderrraises one of them. (#156) - Store-backed resume now authenticates on macOS with a custom
CLAUDE_CONFIG_DIR. There the CLI keeps its OAuth credentials in the Keychain underClaude Code-credentials-<first 8 hex of SHA-256(config dir)>and writes no.credentials.json; the SDK only bridged the default config dir's entry, so the resumed subprocess reported "Not logged in" (and the failed attempt was mirrored into the store). The Keychain is now consulted for a custom config dir too — only when that directory has no.credentials.json, and never underANTHROPIC_API_KEY/CLAUDE_CODE_OAUTH_TOKEN. The seeded copy still has the refresh token removed and mode0600. (#159) - A mirror flush cancelled while it waited behind an in-flight append no longer loses its batch. The batcher detached the pending entries before queueing on its lock, so a
#flushwhose task was stopped there (a timeout around the caller,Task#stop) dropped them: they never reached the store and noMirrorErrorMessagewas produced. The buffer is now detached inside the lock, so those entries go out with the next flush; a frame that arrives while the final flush is still waiting is delivered rather than counted as dropped (Python #1289). The mirror's retry backoff usesKernel#sleepinstead of the deprecatedAsync::Task#sleep. (#159) get_session_messagesandget_subagent_messages(disk andsession_store:) no longer drop the results of parallel tool calls. The CLI parents eachtool_resulton the entry holding its owntool_use, so with several tool calls in one assistant turn only the last-written result lay on the path the readers walk; the others were silently missing, leavingtool_useblocks without atool_result. They are now returned, after the turn'stool_usemessages and before the result the conversation continued from; the chain's own result wins, so a call answered again after a rewind is not returned twice, and the result of a branch a rewind dropped is not brought back. Affected sessions return more messages than before, sooffset:/limit:pages over them shift. (#157)- Session reads, listings and mutations — other fixes (#157):
get_session_messages(disk and store) no longer returns[]for a session whose last entry is an unanswered meta injection (a slash-command or skill body, a stop-hook message, a system reminder written just before the session was closed).- The message readers no longer raise
Encoding::CompatibilityErrorunder a non-UTF-8 locale (LANG=C/LC_ALL=C), nor on a transcript whose last line was cut inside a multibyte character or that holds a raw non-UTF-8 byte (a torn line is skipped; invalid bytes come back as U+FFFD);import_session_to_storeno longer stops half-way on such a line. UnderLC_ALL=Ca non-ASCIICLAUDE_CONFIG_DIR,directory: Dir.pwdand a non-ASCII worktree path no longer raise or hide every worktree. list_sessions(directory:)no longer leaves out the directory's own sessions when it is a subdirectory of a repository with several git worktrees; worktree paths git reports in decomposed Unicode form are NFC-normalized.- Project paths containing a character outside the BMP (an emoji, a CJK Extension B ideograph) map to the project directory the CLI creates (two hyphens per such character).
project_key_for_directorychanges for affected paths: store entries written under the old key byimport_session_to_storeor the store mutations no longer match (the transcript mirror never used that key). rename_session,tag_session,fork_sessionanddelete_sessionno longer raiseErrno::ENOENTwhendirectory:names a removed project directory (a deleted worktree); a missing path is resolved as far as it exists, following symlinks whose target is gone and applying a..after a link to the link's target, asos.path.realpathdoes.list_sessionsno longer returns[]when the config directory path contains[or{.- Disk listings find a first prompt beyond the first 64 KiB of the transcript (a bounded scan of up to 1 MiB) and take
created_atonly from a top-leveltimestamp, as the store path does;created_atis no longer 1 ms low for about one ISO timestamp in eight.docs/sessions.mdlists what the disk path still does not see. fork_sessionwithouttitle:names the fork after the title the listing shows for the source, on disk and withsession_store:alike.get_session_info(session_store:)reports the adapter'smtimeaslast_modified, aslist_sessions(session_store:)does; the store subagent readers skip non-Stringlist_subkeysvalues;rename_session/tag_sessionraiseArgumentErrorfor a title or tag that is not a usable String (tag: falseused to clear the tag, onlynildoes; a binary String, asFile.binreadreturns, is read as UTF-8 instead of failing later with a JSON or encoding error);list_subagentson disk returns each id once.- An optional
SessionStoremethod that raisesNotImplementedErrorwhen called (a stub behind a delegating wrapper, an adapter declining at run time) is treated as not implemented — the documented fallback is taken andrun_session_store_conformanceskips its contracts — while a requiredloadthat raises still fails the resume. docs/sessions.md: rename / tag / fork are safe while the session's CLI runs; delete only sessions whose CLI has exited.
CLIInstaller.installno longer deadlocks when two fibers of oneAsyncreactor install into the same directory. The second fiber blocked the reactor thread inflockwhile the first, which held the lock, was waiting on the network inside the critical section, so neither returned; with the defaultversion: 'stable'this happened even when nothing needed installing. A waiting installer now polls the lock (every 50 ms) instead of blocking its thread. Threads and separate processes were never affected, and mutual exclusion is unchanged. (#152)CLIInstaller.install/install_pinnedno longer fail in a read-only install directory when the requested version is already installed. In an image built as root and run as another user, or on a read-only root filesystem, a boot-timeinstall_pinnedraisedCLIInstallError(EACCES) because the lock file could not be opened, although the binary was intact. When the lock file cannot be opened (EACCES,EROFS,EPERM), a concrete version that is installed and matches its recorded checksum and platform is now returned without the lock. A dist-tag ('stable'/'latest'), a different version or a damaged binary still raises there. (#152)CLIInstallerhonorsHTTPS_PROXY/https_proxy.Net::HTTPonly looks athttp_proxyunless it is told otherwise, even for a TLS connection, so an environment that exported onlyHTTPS_PROXYwas bypassed, and where direct egress is blocked the install timed out with no mention of the proxy. The proxy for each request (redirect hops included) is now resolved from thehttpsURL and passed explicitly, with its credentials;NO_PROXY/no_proxyapply as before, and an environment that only setshttp_proxykeeps working.ALL_PROXYis not read, and onlyhttp://proxy URLs are used.docs/cli-installer.mdhas a new "Proxies and custom CAs" section (HTTPS_PROXY,NO_PROXY,SSL_CERT_FILE). (#152)CLIInstallerfsyncs what it publishes. The downloaded binary andVERSIONare flushed to disk before they are renamed into place (the binary again after it is made executable, so its mode survives too), and the directory after it (best-effort). Previously a power loss shortly after an install could leave an empty or partial executableclaudeunder the published name, which discovery kept selecting until the nextinstall. (#152)CLIInstaller.installwith nodir:names the default directory (vendor/claude) in its error when the process's working directory no longer exists, instead of an empty path.docs/cli-installer.mdstates the locked sequence in the order the code uses (recordVERSION, then place the binary), and that a failed first install never publishes a binary — it can leave the directory, its lock file and, after a failed final rename, a recordedVERSION. (#152)- Shorthand tool schemas with
Array/Hashparameters are advertised correctly.{ order_id: Integer, tags: Array }told the modeltagswas a string, so it sent"fragile,gift"and the handler got a String; a model that did send an array had the call rejected.Array/:arraynow map toarrayandHash/:objecttoobject, intools/listand intools/callargument validation alike. Other shorthand values are unchanged.docs/mcp-servers.mdlists the shorthand types. (#152) - A tool handler that returns a non-Array
:contentgets a clear error.{ content: 'text' }, a single block Hash, or a presentfalseornil, was forwarded unchanged; the CLI rejected it and told the model the server "returned a malformed result", so the text never arrived. It is now an in-bandisErrorresult naming the tool:Tool 'x' must return :content as an Array of content blocks (got String). A result without a:contentkey keeps the existing message. Return the String itself, orcontent: [{ type: 'text', text: ... }]. (#152) create_tool(annotations: { maxResultSizeChars: … }, meta: { … })keeps the size hint. An explicitmeta:replaced the_meta['anthropic/maxResultSizeChars']entry derived from the annotation, so large results were truncated to a preview again. The two are now merged; a size key set inmeta:wins. (#152)OTelObserverno longer counts an API response's token usage once per content block. The CLI sends oneAssistantMessageper content block, and each repeats the response'smessage_idandusage. The observer put that usage on everyclaude_agent.generationspan, so a response with a thinking block and a tool call reported its input, cache-read and cache-creation tokens twice (N times for N blocks). Thegen_ai.usage.*token attributes are now set on the first generation span of eachmessage_idonly. Span count, names and parentage are unchanged, and the session span was already correct. Sums ofgen_ai.usage.*over generation spans drop by the duplication factor.gen_ai.usage.output_tokenson a generation span is still the snapshot taken when the response started;docs/observability.mdnow says so, and that the session span carries the authoritative totals. (#148)examples/advanced_hooks_example.rbandexamples/lifecycle_hooks_example.rbreturned a*HookSpecificOutput#to_has the whole hook output, which leavespermissionDecision,additionalContext,retryandwatchPathsat the top level where the CLI does not read them — the example's "deny" did not deny. They now wrap the typed output inSyncHookJSONOutput. If you copied that pattern, wrap yours the same way (or return{ hook_specific_output: … }). (#149)agents:accepts a Hash for an agent, as its signature says:agents: { reviewer: { description: '…', prompt: '…' } }used to raiseNoMethodErrorat connect. The Hash is read likeAgentDefinition.new(hash), so a misspelled key raises the sameArgumentError. (#149)allowed_tools,disallowed_tools,add_dirs,extra_args,envandload_timeout_mscan be set back tonilafter construction (options.dup_with(allowed_tools: nil),options.extra_args = nil), as their signatures say. The command builder, the transport and store-backed resume used to raise an anonymousNoMethodErrorat connect; they now readnilas the default. (#149, #156, #159)- The
sandbox:option now replaces asandboxsection already present insettings:instead of being added beside it. Settings given as a JSON String, a file path or a String-keyed Hash produced a--settingsvalue with the key twice: under json 3.x connect raised a rawJSON::GeneratorError: detected duplicate key "sandbox", which made the documentedsandbox: falseoverride unusable; under json 2.x it only worked as long as the CLI kept the last duplicate. (#149) - Hooks registered for one event under both a String and a Symbol key (
{ 'PreToolUse' => [a], PreToolUse: [b] }) are all registered, in the order written. The second key used to replace the first silently, soanever ran. (#149) #[]returnsnilfor a writer's name instead of raising.msg[:session_id=],msg['sessionId=']andoptions[:env=]called the setter without an argument (ArgumentError: wrong number of arguments); they now read asnil, like any other name that is not an attribute. (#151)ClaudeAgentOptionsreports'[]'as an unknown option.ClaudeAgentOptions.new('[]' => 1)raisedwrong number of arguments (given 1, expected 2); it now raises the usualunknown ClaudeAgentOptions option: "[]". The keys'=','!'and'=='used to resolve to#==,#!=and#===and were dropped without an error; they now raise the sameArgumentError. Setters you define on your ownClaudeAgentOptionssubclass are still accepted. (#151)Type#inspectno longer raises on a value it cannot render. ABasicObjectattribute raisedNoMethodError(nil?), and a String, Hash or Array subclass whose#length/#sizeraises took#inspectdown with it — inside a logger orputs. Such a value now shows as#<ClassName>(#<?>for aBasicObject) and the rest of the object still prints.Interrupt,SystemExitand other non-StandardErrorexceptions are not rescued. (#151)cli_path:given as aPathnamefailed at connect. The signature has always allowed it, but the spawn raisedno implicit conversion of Pathname into String. The transport now converts it once, before the version check and the spawn. (#156)- No more
Async::Task#sleep is deprecatedwarnings underruby -wfrom the transport: while it waited for the CLI to exit, or for the version check, it printed one every 50 ms. It now usesKernel#sleep, which parks only the calling fiber on a reactor. (#156) - The Postgres reference adapter (
examples/session_stores/) stores entries in ajsoncolumn instead ofjsonb, which rejects the JSON escape for U+0000: one transcript entry with a NUL character (binary tool output) failed the wholeINSERTand the mirror dropped that batch. An existing table is not changed bycreate_schema; the README gives the migration (ALTER TABLE … ALTER COLUMN entry TYPE json USING entry::json). (#159) .yardoptsis packaged in the gem. rubydoc.info builds the API reference from the gem's own files; without it YARD ran with its defaults, so the reference listed every@api privateinternal (Query,FiberBoundary,MessageParser, …) next to the public API and did not renderCHANGELOG.mdorUPGRADING-1.0.md. Takes effect on rubydoc.info from this release on. (#155)- Documentation that contradicted the code (#155):
docs/types.mdlists every message and content-block class the SDK can yield (ToolProgressMessage,AuthStatusMessage,ToolUseSummaryMessage,PromptSuggestionMessage,ServerToolUseBlockandServerToolResultBlockwere missing), with a table of the system and progress messages and what#dataholds; theSandboxIgnoreViolationsrow (removed in 0.12.0) is gone.docs/errors.mddocumentsCLIInstallError, and that a control request the CLI rejects raises a plainStandardError, not aClaudeSDKError.- Hooks, custom tools and permission callbacks do not require
Client(docs/client.md, the README and the bundled skill said they did);query()andaskhave run all three for many releases. - The custom-transport contract is stated once:
connect,write,read_messages,end_inputandcloseare required,ready?is optional.docs/client.mdlisted six methods and the bundled skill four (a transport written from the skill's list madequery()hang). The YARD ofClaudeAgentSDK::Transportno longer calls it an internal API. - README: links are absolute GitHub URLs (relative ones were 404 on rubydoc.info, and the banner was missing from the installed gem); the CLI install snippet uses
CLIInstaller.install_pinnedinstead of the literal2.1.220; a new Authentication section says how the CLI authenticates and what a missing login looks like; the comparison table's Python column is corrected. - The bundled skill's configure examples no longer put
ENV.fetch('ANTHROPIC_API_KEY')into the default options (the CLI inherits the environment anyway, and the line made every boot without the key raiseKeyError); the skill anddocs/options.mdsay that aCLAUDE_CLI_PATHnaming a missing file is skipped and discovery continues. - Four clarifications, each checked against Claude Code 2.1.287:
sandbox: { enabled: true }does not make the sandbox a requirement (setfail_if_unavailable: true; otherwise the CLI warns on stderr and runs unsandboxed);setting_sources: []does not stop the MCP servers of the logged-in claude.ai account (strict_mcp_config: truedoes);Client#query(prompt, session_id:)does not start a separate conversation; an explicittools:list must include'Skill'forskills:to work. Sandbox settings travel inside--settings, not a--sandboxflag. - The
Clientexamples in the guides and theClientYARD useClient.open(theconnect…disconnectshape they showed leaves the session open when the block raises). .claude-plugin/marketplace.jsonno longer declares a version for the plugin (it said0.13.1);SECURITY.mdstates the supported versions for 1.x;examples/message_types_example.rbhandles all 28 typed message classes.- In the pages other PRs of this batch own: the documented Gemfile lines for the OpenTelemetry setup list the
base64gem, which is no longer a default gem on Ruby 3.4 (docs/observability.md, #148);docs/cli-installer.mdsays an unusableCLAUDE_CLI_PATHis skipped without a warning and discovery continues, instead of "no discovery at all" (#156);docs/rails.mdsays hooks and custom tools work withquerytoo (#158).
Usage
# Multi-user hosts: keep one user's "remember …" out of every other user's session
ClaudeAgentSDK.configure do |config|
config.default_options = { env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } }
end
# A tools: String now restricts the session to the tools it names (it used to be ignored)
options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: 'Read,Grep')What's changed
- #165 Bump PINNED_CLI_VERSION to 2.1.288 — @github-actions
- #164 Keep the stderr drain thread off rspec doubles in connect specs — @ya-luotao
- #163 CHANGELOG for the October exploration fixes (#148–#161) — merge last — @ya-luotao
- #161 Let a disconnect from another thread finish the CLI's graceful shutdown — @ya-luotao
- #160 Accept the natural Ruby spellings in Hash-form options (sandbox keys, Symbol type:, String tools:, Pathname) — @ya-luotao
- #159 Store-backed resume: Keychain for a custom config dir, a scrubbed preserved dir, no batch lost to a cancelled flush — @ya-luotao
- #158 Railtie wrapper: stop reporting SDK-handled errors; make the Rails guide say what callbacks really see — @ya-luotao
- #157 Fix session reads, listing and mutations (parallel tool results, worktrees, encodings, long and odd paths) — @ya-luotao
- #156 Resolve the CLI to one absolute path; contain stderr-callback failures; warn when the sandbox is off — @ya-luotao
- #155 Docs and packaging: make the published reference match the code — @ya-luotao
- #154 Fix runtime lifecycle defects in Query, FiberBoundary, query() and Client — @ya-luotao
- #153 Harden the test suite and CI (seed order, Ruby 4.0, hang bounds, protocol pins, keyless CLI smoke) — @ya-luotao
- #152 Fix CLI installer defects (fiber deadlock, proxy, read-only dir, fsync) and SDK MCP schema/result bugs — @ya-luotao
- #151 Filter more credentials in #inspect, make it never raise, fix #[] with a setter-shaped name — @ya-luotao
- #150 Answer every failing callback: non-StandardError failures and unencodable messages — @ya-luotao
- #149 Honor Hash hook outputs in any key style; fix other wire-shape contract bugs — @ya-luotao
- #148 Fix OTelObserver token usage per response; trace sessions that fail before init — @ya-luotao
- #147 Bump PINNED_CLI_VERSION to 2.1.287 — @github-actions
- #146 Keep the pin's CHANGELOG entry in the bump PR; batch pin-only releases — @ya-luotao
- #132 Bump the github-actions group across 1 directory with 3 updates — @dependabot
Full Changelog: v1.1.0...v1.2.0
gem install claude-agent-sdk -v 1.2.0