The SDK runs the claude CLI as a subprocess, so a deploy is only reproducible if the CLI version is pinned with it. ClaudeAgentSDK::CLIInstaller downloads a pinned binary from the official release endpoint into a project-local directory (vendor/claude by default) — checksum-verified, no npm/Node at runtime, and nothing extra shipped inside the gem.
require 'claude_agent_sdk'
# Install the CLI version this gem release was tested against
# (CLIInstaller::PINNED_CLI_VERSION) — recommended: bumping the gem
# then carries the CLI forward with it, e.g. via Dependabot.
ClaudeAgentSDK::CLIInstaller.install_pinned
# => "/app/vendor/claude/claude"
# Or pin a concrete version of your own:
ClaudeAgentSDK::CLIInstaller.install(version: 'x.y.z', dir: '/opt/claude')
# 'stable' (the default) and 'latest' are floating dist-tags, not pins:
# they re-resolve on every call, so a rebuild may install a newer CLI.
ClaudeAgentSDK::CLIInstaller.install(version: 'stable')
# nil unless a binary is already installed there
ClaudeAgentSDK::CLIInstaller.installed_pathinstall is idempotent and safe to run concurrently, so it fits bin/setup, a cached Docker layer, and every process of a multi-process boot:
- The install directory's
VERSIONfile records the installed version, verified SHA-256, and target platform (OS, architecture, libc). The shortcut re-hashes the vendored binary (~0.1s for the real 245MB binary) and only skips the download when all three match — a truncated binary or a cache copied from another platform is reinstalled instead of trusted. It makes no network request, so same-platform repeat boots work offline — with a pinned concrete version;'stable'/'latest'must always re-resolve through the endpoint, which is one more reason to pin in production. Older one- or two-line metadata lacks a platform and requires one online reinstall to migrate; subsequent pinned installs work offline again. - An exclusive
flockon<dir>/.install.lockcovers the whole check → download → record → place sequence, so parallel installs into one directory don't race — whether they come from separate processes, threads, or fibers of oneAsyncreactor (a waiting installer polls the lock rather than blocking its thread on it); the loser simply observes the finished install. - The shortcut also works where the running process cannot write the install directory: an image built as root and run as another user, or a read-only root filesystem.
installcannot open its lock file there (EACCES,EROFSorEPERM), and still returns the binary when the request is a concrete version that is already installed and intact — the sameVERSIONand SHA-256 check, without the lock. Anything that would need a write raisesCLIInstallErroras before: a dist-tag ('stable'/'latest', which has to be resolved and may have to be installed), a different version, a damaged binary, an empty directory.
Failures (unsupported platform, invalid version, HTTP error, response-size cap, oversized download, checksum mismatch, filesystem errors) raise ClaudeAgentSDK::CLIInstallError.
A failed install never breaks a working one. The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next install redoes it cleanly. The binary and VERSION are also flushed to disk (fsync) before they are renamed into place, so a machine that loses power right after an install comes back with a complete binary or with the previous state, not with a truncated file under the published name. A first install that fails never publishes a binary. It can leave the install directory and its .install.lock behind and — when the step that failed is the last one, the rename — a VERSION that already records the version. Nothing trusts that file on its own: discovery finds no binary there and moves on, and the next install redoes it cleanly.
PINNED_CLI_VERSION follows the CLI the Python SDK bundles. A bot PR moves it on main, usually within a few days, but the new pin reaches rubygems only with the next gem release: pin-only changes are batched rather than released one by one. A CLI security fix, or an SDK change that needs a newer CLI, gets a release sooner. Each release's CHANGELOG says when the pin moved. So install_pinned can trail the newest CLI by days. That is the price of installing the version the gem was tested with.
A gem upgrade can therefore move your CLI, even in a patch release. If Dependabot merges gem patches for you, check the CHANGELOG for a PINNED_CLI_VERSION entry: a new CLI can behave differently even where the SDK's API does not change.
To run a newer CLI before a gem release pins it, pin it yourself with install(version: 'x.y.z'), or CLAUDE_CLI_VERSION=x.y.z for the rake task and the bin/setup example below. Go back to install_pinned once a gem release catches up. 'stable' and 'latest' follow the newest CLI automatically, but they are not pins: a rebuild can pick up a different version.
With no dir:, install, install_pinned and installed_path use CLIInstaller.default_dir: vendor/claude under CLIInstaller.root, or under the process's working directory at call time while root is unset (the default). Transport discovery uses the same directory, so installing and finding the binary agree.
Set root when a process that runs agents does not start in the project root — a daemonized worker, a job runner launched from /, a systemd unit without WorkingDirectory=. Otherwise that process looks for vendor/claude under its own working directory, misses the vendored binary, and falls through to whatever claude is on PATH:
# early in boot, before the first query
ClaudeAgentSDK::CLIInstaller.root = '/srv/myapp' # a String or a Pathname
ClaudeAgentSDK::CLIInstaller.default_dir # => "/srv/myapp/vendor/claude"A relative path is resolved against the working directory once, when you set it. nil restores the working-directory default. In a Rails app you don't need this line: the Railtie sets root to Rails.root during boot (see docs/rails.md). An explicit dir: argument always wins over root.
The vendored directory is trusted input: anything that can write to it can replace the binary the SDK executes. Keep it inside your deploy artifact, owned by the deploy user and not world-writable, exactly as you would treat
bin/.
# Dockerfile — the gem's pinned CLI version in its own cached layer
RUN bundle exec ruby -e "require 'claude_agent_sdk'; \
ClaudeAgentSDK::CLIInstaller.install_pinned"#!/usr/bin/env ruby
# bin/setup — gem's pin by default, overridable per developer
require 'claude_agent_sdk'
version = ENV.fetch('CLAUDE_CLI_VERSION', ClaudeAgentSDK::CLIInstaller::PINNED_CLI_VERSION)
puts ClaudeAgentSDK::CLIInstaller.install(version: version)Rails apps get claude_agent_sdk:install_cli from the gem's Railtie; any other project can load it from its Rakefile:
# Rakefile (non-Rails)
require 'claude_agent_sdk/tasks' # loads only CLIInstaller, not the whole SDKbin/rails claude_agent_sdk:install_cli # Rails: installs PINNED_CLI_VERSION into Rails.root/vendor/claude
rake claude_agent_sdk:install_cli # elsewhere: into CLIInstaller.default_dir (vendor/claude under the working directory unless root is set)
rake claude_agent_sdk:install_cli CLAUDE_CLI_VERSION=x.y.z # a version of your own, or 'stable' / 'latest'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:
RUN bin/rails claude_agent_sdk:install_cliThe 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.
The installer downloads over HTTPS with Ruby's Net::HTTP and reads the usual environment variables:
HTTPS_PROXY(orhttps_proxy) names the proxy to download through, as anhttp://URL:HTTPS_PROXY=http://proxy.corp.example:3128, withuser:password@in front of the host for an authenticating proxy (percent-encode special characters). The download is tunnelled through it withCONNECT, so TLS still ends at the release endpoint.NO_PROXY(orno_proxy) lists the hosts, domain suffixes and IP ranges to reach directly, separated by commas.- With neither spelling of
HTTPS_PROXYset,Net::HTTP's own default applies: it goes throughhttp_proxywhen that is set. SSL_CERT_FILEpoints OpenSSL at another CA bundle, which is what a TLS-inspecting proxy needs. Set it in the environment the process starts with. The server certificate is always verified; there is no switch to turn that off.
This is not everything curl understands. ALL_PROXY is not read, and only an http:// proxy URL is used: a value without a scheme (proxy.corp.example:3128), a socks5:// proxy or an https:// one is ignored.
darwin-arm64, darwin-x64 (Rosetta 2 gets the arm64 build), linux-x64, linux-arm64, and the -musl variants. Windows is not supported.
With no explicit cli_path: in ClaudeAgentOptions, the transport probes in this order:
CLAUDE_CLI_PATH— a path to the CLI, used when it names an executable regular file (a relative value is resolved against the process's working directory, notcwd:). A value that names anything else — a missing file, a directory, a file that is not executable — is skipped without a warning, and discovery continues with the steps below- The vendored binary (
CLIInstaller.installed_path, i.e.vendor/claudeunderCLIInstaller.rootor the working directory — see above) — deliberately ahead ofPATH, so a pinned install beats whatever is installed globally claudeon the process'sPATH— the first executable regular file of that name. The SDK searchesPATHitself and does not runwhich. APATHpassed inenv:belongs to the session and is not searched here. When the process has noPATHat all, the system directories are searched (/usr/local/bin,/usr/bin,/bin), as Ruby's own command lookup would — never the working directory- Common install locations (
~/.claude/local/claude,/usr/local/bin/claude, …) — only an executable regular file counts, and the~ones are skipped when there is no usable home directory (HOME unset with no passwd entry, or a non-absolute HOME)
However the CLI is named, the transport settles on one absolute path before it runs anything, and uses that path for both the version check and the session. Anything relative is resolved against the process's working directory, never against cwd: — a relative cli_path: (a leading ~ expands to the home directory), and a PATH hit that came from a relative PATH entry (bin, ., an empty entry). A PATH entry that is ~ or starts with ~/ is not one of those: it is expanded against the home directory, as Ruby's own command lookup does. A bare cli_path: 'claude' is looked up by the SDK in the same way, but on the PATH the session will get: the one in env: when you set one there, otherwise the process's. A cli_path: that names no file raises CLINotFoundError from connect.