claude-agent-sdk 0.16.7 → 0.16.9

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.
@@ -0,0 +1,153 @@
1
+ # Custom Tools (SDK MCP Servers)
2
+
3
+ A **custom tool** is a Ruby proc/lambda that you can offer to Claude, for Claude to invoke as needed. Custom tools are implemented as in-process MCP servers that run directly within your Ruby application, eliminating the need for separate processes that regular MCP servers require.
4
+
5
+ **Implementation:** This SDK uses the [official Ruby MCP SDK](https://github.com/modelcontextprotocol/ruby-sdk) (`mcp` gem) internally, providing full protocol compliance while offering a simpler block-based API for tool definition.
6
+
7
+ ## Creating a Simple Tool
8
+
9
+ ```ruby
10
+ require 'claude_agent_sdk'
11
+ require 'async'
12
+
13
+ greet_tool = ClaudeAgentSDK.create_tool(
14
+ 'greet', 'Greet a user', { name: :string },
15
+ annotations: { title: 'Greeter', readOnlyHint: true }
16
+ ) do |args|
17
+ { content: [{ type: 'text', text: "Hello, #{args[:name]}!" }] }
18
+ end
19
+
20
+ server = ClaudeAgentSDK.create_sdk_mcp_server(
21
+ name: 'my-tools',
22
+ version: '1.0.0',
23
+ tools: [greet_tool]
24
+ )
25
+
26
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
27
+ mcp_servers: { tools: server },
28
+ allowed_tools: ['mcp__tools__greet']
29
+ )
30
+
31
+ Async do
32
+ client = ClaudeAgentSDK::Client.new(options: options)
33
+ client.connect
34
+ client.query("Greet Alice")
35
+ client.receive_response { |msg| puts msg }
36
+ client.disconnect
37
+ end.wait
38
+ ```
39
+
40
+ ## Pre-built JSON Schemas
41
+
42
+ If your schemas come from another library (e.g., [RubyLLM](https://github.com/crmne/ruby_llm)) that deep-stringifies keys, the SDK handles them transparently — both symbol-keyed and string-keyed schemas are accepted and normalized:
43
+
44
+ ```ruby
45
+ # Symbol keys (standard Ruby)
46
+ ClaudeAgentSDK.create_tool('save', 'Save a fact', {
47
+ type: 'object',
48
+ properties: { fact: { type: 'string' } },
49
+ required: ['fact']
50
+ }) { |args| { content: [{ type: 'text', text: "Saved: #{args[:fact]}" }] } }
51
+
52
+ # String keys (e.g., from RubyLLM or JSON.parse)
53
+ ClaudeAgentSDK.create_tool('save', 'Save a fact', {
54
+ 'type' => 'object',
55
+ 'properties' => { 'fact' => { 'type' => 'string' } },
56
+ 'required' => ['fact']
57
+ }) { |args| { content: [{ type: 'text', text: "Saved: #{args[:fact]}" }] } }
58
+ ```
59
+
60
+ ## Benefits Over External MCP Servers
61
+
62
+ - **No subprocess management** — runs in the same process as your application
63
+ - **Better performance** — no IPC overhead for tool calls
64
+ - **Simpler deployment** — single Ruby process instead of multiple
65
+ - **Easier debugging** — all code runs in the same process
66
+ - **Direct access** — tools can directly access your application's state
67
+
68
+ ## Calculator Example
69
+
70
+ ```ruby
71
+ add_tool = ClaudeAgentSDK.create_tool('add', 'Add two numbers', { a: :number, b: :number }) do |args|
72
+ result = args[:a] + args[:b]
73
+ { content: [{ type: 'text', text: "#{args[:a]} + #{args[:b]} = #{result}" }] }
74
+ end
75
+
76
+ divide_tool = ClaudeAgentSDK.create_tool('divide', 'Divide numbers', { a: :number, b: :number }) do |args|
77
+ if args[:b] == 0
78
+ { content: [{ type: 'text', text: 'Error: Division by zero' }], is_error: true }
79
+ else
80
+ { content: [{ type: 'text', text: "Result: #{args[:a] / args[:b]}" }] }
81
+ end
82
+ end
83
+
84
+ calculator = ClaudeAgentSDK.create_sdk_mcp_server(
85
+ name: 'calculator',
86
+ tools: [add_tool, divide_tool]
87
+ )
88
+
89
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
90
+ mcp_servers: { calc: calculator },
91
+ allowed_tools: ['mcp__calc__add', 'mcp__calc__divide']
92
+ )
93
+ ```
94
+
95
+ ## Mixed Server Support
96
+
97
+ You can use both SDK and external MCP servers together:
98
+
99
+ ```ruby
100
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
101
+ mcp_servers: {
102
+ internal: sdk_server, # In-process SDK server
103
+ external: { # External subprocess server
104
+ type: 'stdio',
105
+ command: 'external-server'
106
+ }
107
+ }
108
+ )
109
+ ```
110
+
111
+ ## MCP Resources and Prompts
112
+
113
+ SDK MCP servers can also expose **resources** (data sources) and **prompts** (reusable templates):
114
+
115
+ ```ruby
116
+ config_resource = ClaudeAgentSDK.create_resource(
117
+ uri: 'config://app/settings',
118
+ name: 'Application Settings',
119
+ description: 'Current app configuration',
120
+ mime_type: 'application/json'
121
+ ) do
122
+ config_data = { app_name: 'MyApp', version: '1.0.0' }
123
+ {
124
+ contents: [{
125
+ uri: 'config://app/settings',
126
+ mimeType: 'application/json',
127
+ text: JSON.pretty_generate(config_data)
128
+ }]
129
+ }
130
+ end
131
+
132
+ review_prompt = ClaudeAgentSDK.create_prompt(
133
+ name: 'code_review',
134
+ description: 'Review code for best practices',
135
+ arguments: [{ name: 'code', description: 'Code to review', required: true }]
136
+ ) do |args|
137
+ {
138
+ messages: [{
139
+ role: 'user',
140
+ content: { type: 'text', text: "Review this code: #{args[:code]}" }
141
+ }]
142
+ }
143
+ end
144
+
145
+ server = ClaudeAgentSDK.create_sdk_mcp_server(
146
+ name: 'dev-tools',
147
+ tools: [my_tool],
148
+ resources: [config_resource],
149
+ prompts: [review_prompt]
150
+ )
151
+ ```
152
+
153
+ See [examples/mcp_calculator.rb](../examples/mcp_calculator.rb) and [examples/mcp_resources_prompts_example.rb](../examples/mcp_resources_prompts_example.rb) for complete examples.
@@ -0,0 +1,126 @@
1
+ # Observability (OpenTelemetry / Langfuse)
2
+
3
+ The SDK includes a built-in **observer interface** and an **OpenTelemetry observer** for tracing agent sessions. Traces are emitted using standard `gen_ai.*` semantic conventions, compatible with Langfuse, Jaeger, Datadog, and any OTel backend.
4
+
5
+ ## How It Works
6
+
7
+ Register observers via `ClaudeAgentOptions`. The SDK calls `on_message` for every parsed message in both `query()` and `Client`, and `on_close` when the session ends. Observer errors are silently rescued so they never crash your application.
8
+
9
+ ```
10
+ claude_agent.session (root span — one per query/session)
11
+ ├── claude_agent.generation (per AssistantMessage, with model + token usage)
12
+ ├── claude_agent.tool.Bash (per tool call, open on ToolUseBlock, close on ToolResultBlock)
13
+ ├── claude_agent.tool.Read
14
+ ├── claude_agent.generation
15
+ └── ...
16
+ ```
17
+
18
+ ## Setup with Langfuse
19
+
20
+ **1. Install the OTel gems** (not bundled with the SDK — you choose your exporter):
21
+
22
+ ```bash
23
+ gem install opentelemetry-sdk opentelemetry-exporter-otlp
24
+ ```
25
+
26
+ Or add to your Gemfile:
27
+
28
+ ```ruby
29
+ gem 'opentelemetry-sdk', '~> 1.4'
30
+ gem 'opentelemetry-exporter-otlp', '~> 0.28'
31
+ ```
32
+
33
+ **2. Configure the OTel SDK** to export to your Langfuse instance:
34
+
35
+ ```ruby
36
+ require 'base64'
37
+ require 'opentelemetry/sdk'
38
+ require 'opentelemetry/exporter/otlp'
39
+
40
+ # Langfuse authenticates via Basic Auth over OTLP
41
+ public_key = ENV['LANGFUSE_PUBLIC_KEY']
42
+ secret_key = ENV['LANGFUSE_SECRET_KEY']
43
+ auth = Base64.strict_encode64("#{public_key}:#{secret_key}")
44
+
45
+ # Self-hosted or cloud: https://cloud.langfuse.com (EU) / https://us.cloud.langfuse.com (US)
46
+ langfuse_host = ENV.fetch('LANGFUSE_HOST', 'https://cloud.langfuse.com')
47
+
48
+ OpenTelemetry::SDK.configure do |c|
49
+ c.service_name = 'my-agent-app'
50
+ c.add_span_processor(
51
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
52
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
53
+ endpoint: "#{langfuse_host}/api/public/otel/v1/traces",
54
+ headers: {
55
+ 'Authorization' => "Basic #{auth}",
56
+ 'x-langfuse-ingestion-version' => '4'
57
+ }
58
+ )
59
+ )
60
+ )
61
+ end
62
+ ```
63
+
64
+ **3. Create the observer and run a query:**
65
+
66
+ ```ruby
67
+ require 'claude_agent_sdk'
68
+ require 'claude_agent_sdk/instrumentation'
69
+
70
+ observer = ClaudeAgentSDK::Instrumentation::OTelObserver.new(
71
+ 'langfuse.session.id' => 'my-session-123', # optional: group traces by session
72
+ 'user.id' => 'user-42' # optional: tag with user ID
73
+ )
74
+
75
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
76
+ observers: [observer],
77
+ allowed_tools: ['Bash', 'Read'],
78
+ permission_mode: 'bypassPermissions'
79
+ )
80
+
81
+ ClaudeAgentSDK.query(prompt: "List files in /tmp", options: options) do |msg|
82
+ puts msg.text if msg.is_a?(ClaudeAgentSDK::AssistantMessage)
83
+ end
84
+
85
+ # For long-running apps, flush before exit:
86
+ # OpenTelemetry.tracer_provider.shutdown
87
+ ```
88
+
89
+ ## Span Attributes
90
+
91
+ The OTel observer sets attributes using both `gen_ai.*` (OTel GenAI) and OpenInference conventions for maximum backend compatibility:
92
+
93
+ | Span | Type | Key Attributes |
94
+ |------|------|----------------|
95
+ | `claude_agent.session` | `agent` | `gen_ai.system`, `gen_ai.request.model`, `session.id`, `input.value`, `output.value`, `gen_ai.usage.cost`, `llm.cost.total` |
96
+ | `claude_agent.generation` | `generation` | `gen_ai.response.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `output.value` |
97
+ | `claude_agent.tool.*` | `tool` | `tool.name`, `input.value`, `output.value` |
98
+
99
+ Events (`api_retry`, `rate_limit`, `tool_progress`) are recorded on the root span.
100
+
101
+ The `langfuse.observation.type` attribute is set on each span (`agent`/`generation`/`tool`) to enable Langfuse's **trace flow diagram** (DAG graph visualization).
102
+
103
+ ## Custom Observers
104
+
105
+ Implement the `Observer` module to build your own instrumentation:
106
+
107
+ ```ruby
108
+ class MyObserver
109
+ include ClaudeAgentSDK::Observer
110
+
111
+ def on_message(message)
112
+ case message
113
+ when ClaudeAgentSDK::ResultMessage
114
+ puts "Cost: $#{message.total_cost_usd}, Tokens: #{message.usage}"
115
+ end
116
+ end
117
+
118
+ def on_close
119
+ puts "Session ended"
120
+ end
121
+ end
122
+
123
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [MyObserver.new])
124
+ ```
125
+
126
+ See [examples/otel_langfuse_example.rb](../examples/otel_langfuse_example.rb) for a complete multi-tool example.
data/docs/rails.md ADDED
@@ -0,0 +1,199 @@
1
+ # Rails Integration
2
+
3
+ The SDK integrates well with Rails applications. Below are the common patterns.
4
+
5
+ ## Thread-keyed libraries are safe inside SDK callbacks
6
+
7
+ The SDK depends on [`async`](https://github.com/socketry/async), which installs a Fiber scheduler that multiplexes fibers onto a single OS thread and intercepts IO so blocking calls yield to siblings. Most mature Ruby libraries are thread-safe but not fiber-safe — they key state (checked-out DB connections, per-thread caches, request stores) on `Thread.current`. When the scheduler interleaves two fibers on one thread, those fibers share the same state slot, and interleaved IO on a shared connection silently corrupts wire protocols. This affects every DB driver keyed by thread (`pg`, `mysql2`, `sqlite3`), ActiveRecord's connection pool, and HTTP/cache clients pooled per thread.
8
+
9
+ You do **not** need to think about this. The SDK hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active and inherits the ordinary thread-keyed assumptions every Rails / Sidekiq / Kamal app already makes:
10
+
11
+ ```ruby
12
+ tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args|
13
+ user = User.find(args[:id]) # just works
14
+ { content: [{ type: 'text', text: user.name }] }
15
+ end
16
+
17
+ ClaudeAgentSDK.query(prompt: '...') do |message|
18
+ Message.create!(role: 'assistant', body: message.to_s) # just works
19
+ end
20
+ ```
21
+
22
+ The trade-off: because callbacks run on a plain thread rather than inside an `Async::Task`, fiber-specific primitives aren't available to them — `Async::Task.current` will raise "No async task available". If a callback wants cooperative concurrency it should open its own `Async { }` block. In practice, callbacks typically do some Ruby work, call external services, and return — so this rarely matters. If you wrap your own call site in an outer `Async { }` block, the scheduler is visible to your code again; you've opted in, and whatever fiber-safety rules your app uses apply there.
23
+
24
+ ## ActionCable Streaming
25
+
26
+ Stream Claude responses to the frontend in real-time:
27
+
28
+ ```ruby
29
+ # app/jobs/chat_agent_job.rb
30
+ class ChatAgentJob < ApplicationJob
31
+ queue_as :claude_agents
32
+
33
+ def perform(chat_id, message_content)
34
+ Async do
35
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
36
+ system_prompt: { type: 'preset', preset: 'claude_code' },
37
+ permission_mode: 'bypassPermissions'
38
+ )
39
+
40
+ client = ClaudeAgentSDK::Client.new(options: options)
41
+
42
+ begin
43
+ client.connect
44
+ client.query(message_content)
45
+
46
+ client.receive_response do |message|
47
+ case message
48
+ when ClaudeAgentSDK::AssistantMessage
49
+ ChatChannel.broadcast_to(chat_id, { type: 'chunk', content: message.text })
50
+ when ClaudeAgentSDK::ResultMessage
51
+ ChatChannel.broadcast_to(chat_id, {
52
+ type: 'complete',
53
+ content: message.result,
54
+ cost: message.total_cost_usd
55
+ })
56
+ end
57
+ end
58
+ ensure
59
+ client.disconnect
60
+ end
61
+ end.wait
62
+ end
63
+ end
64
+ ```
65
+
66
+ ## Session Resumption
67
+
68
+ Persist Claude sessions for multi-turn conversations:
69
+
70
+ ```ruby
71
+ # app/models/chat_session.rb
72
+ class ChatSession < ApplicationRecord
73
+ # Columns: id, claude_session_id, user_id, created_at, updated_at
74
+
75
+ def send_message(content)
76
+ options = build_options
77
+ client = ClaudeAgentSDK::Client.new(options: options)
78
+
79
+ Async do
80
+ client.connect
81
+ client.query(content, session_id: claude_session_id ? nil : generate_session_id)
82
+
83
+ client.receive_response do |message|
84
+ update!(claude_session_id: message.session_id) if message.is_a?(ClaudeAgentSDK::ResultMessage)
85
+ end
86
+ ensure
87
+ client.disconnect
88
+ end.wait
89
+ end
90
+
91
+ private
92
+
93
+ def build_options
94
+ opts = { permission_mode: 'bypassPermissions', setting_sources: [] }
95
+ opts[:resume] = claude_session_id if claude_session_id.present?
96
+ ClaudeAgentSDK::ClaudeAgentOptions.new(**opts)
97
+ end
98
+
99
+ def generate_session_id
100
+ "chat_#{id}_#{Time.current.to_i}"
101
+ end
102
+ end
103
+ ```
104
+
105
+ ## Background Jobs with Error Handling
106
+
107
+ ```ruby
108
+ class ClaudeAgentJob < ApplicationJob
109
+ queue_as :claude_agents
110
+ retry_on ClaudeAgentSDK::ProcessError, wait: :polynomially_longer, attempts: 3
111
+
112
+ def perform(task_id)
113
+ task = Task.find(task_id)
114
+ Async { execute_agent(task) }.wait
115
+ rescue ClaudeAgentSDK::CLINotFoundError
116
+ task.update!(status: 'failed', error: 'Claude CLI not installed')
117
+ raise
118
+ end
119
+
120
+ private
121
+
122
+ def execute_agent(task)
123
+ # ... agent execution
124
+ end
125
+ end
126
+ ```
127
+
128
+ ## HTTP MCP Servers
129
+
130
+ Connect to remote tool services:
131
+
132
+ ```ruby
133
+ mcp_servers = {
134
+ 'api_tools' => ClaudeAgentSDK::McpHttpServerConfig.new(
135
+ url: ENV['MCP_SERVER_URL'],
136
+ headers: { 'Authorization' => "Bearer #{ENV['MCP_TOKEN']}" }
137
+ ).to_h
138
+ }
139
+
140
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
141
+ mcp_servers: mcp_servers,
142
+ permission_mode: 'bypassPermissions'
143
+ )
144
+ ```
145
+
146
+ ## Observability in Rails
147
+
148
+ Add OpenTelemetry tracing to your Rails app with a single initializer:
149
+
150
+ ```ruby
151
+ # config/initializers/opentelemetry.rb
152
+ require 'base64'
153
+ require 'opentelemetry/sdk'
154
+ require 'opentelemetry/exporter/otlp'
155
+
156
+ if ENV['LANGFUSE_PUBLIC_KEY'].present?
157
+ auth = Base64.strict_encode64("#{ENV['LANGFUSE_PUBLIC_KEY']}:#{ENV['LANGFUSE_SECRET_KEY']}")
158
+ langfuse_host = ENV.fetch('LANGFUSE_HOST', 'https://cloud.langfuse.com')
159
+
160
+ OpenTelemetry::SDK.configure do |c|
161
+ c.service_name = Rails.application.class.module_parent_name.underscore
162
+ c.add_span_processor(
163
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
164
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
165
+ endpoint: "#{langfuse_host}/api/public/otel/v1/traces",
166
+ headers: {
167
+ 'Authorization' => "Basic #{auth}",
168
+ 'x-langfuse-ingestion-version' => '4'
169
+ }
170
+ )
171
+ )
172
+ )
173
+ end
174
+ end
175
+ ```
176
+
177
+ ```ruby
178
+ # config/initializers/claude_agent_sdk.rb
179
+ require 'claude_agent_sdk/instrumentation'
180
+
181
+ ClaudeAgentSDK.configure do |config|
182
+ config.default_options = {
183
+ permission_mode: 'bypassPermissions',
184
+ observers: ENV['LANGFUSE_PUBLIC_KEY'].present? ? [
185
+ # Use a lambda so each query gets a fresh observer instance (thread-safe).
186
+ # A single shared instance would have its span state clobbered by concurrent requests.
187
+ -> { ClaudeAgentSDK::Instrumentation::OTelObserver.new }
188
+ ] : []
189
+ }
190
+ end
191
+ ```
192
+
193
+ Then every `ClaudeAgentSDK.query` and `Client` session automatically gets traced — no per-call wiring needed. The lambda factory ensures each request gets its own observer with isolated span state, safe for concurrent Puma/Sidekiq workers.
194
+
195
+ See:
196
+ - [examples/rails_actioncable_example.rb](../examples/rails_actioncable_example.rb)
197
+ - [examples/rails_background_job_example.rb](../examples/rails_background_job_example.rb)
198
+ - [examples/session_resumption_example.rb](../examples/session_resumption_example.rb)
199
+ - [examples/http_mcp_server_example.rb](../examples/http_mcp_server_example.rb)
data/docs/sessions.md ADDED
@@ -0,0 +1,101 @@
1
+ # Session Browsing & Mutations
2
+
3
+ Browse, read, mutate, fork, and resume Claude Code sessions directly from Ruby — no CLI subprocess required. These APIs read and write `~/.claude/projects/` JSONL files directly, respecting the `CLAUDE_CONFIG_DIR` environment variable and auto-detecting git worktrees.
4
+
5
+ ## Listing Sessions
6
+
7
+ ```ruby
8
+ # All sessions (sorted by most recent first)
9
+ sessions = ClaudeAgentSDK.list_sessions
10
+ sessions.each do |session|
11
+ puts "#{session.session_id}: #{session.summary} (#{session.git_branch})"
12
+ end
13
+
14
+ # For a specific directory
15
+ ClaudeAgentSDK.list_sessions(directory: '/path/to/project', limit: 10)
16
+
17
+ # Paginate with offset
18
+ ClaudeAgentSDK.list_sessions(directory: '.', limit: 10, offset: 10)
19
+
20
+ # Include git worktree sessions
21
+ ClaudeAgentSDK.list_sessions(directory: '.', include_worktrees: true)
22
+ ```
23
+
24
+ Each `SDKSessionInfo` includes: `session_id`, `summary`, `last_modified`, `file_size`, `custom_title`, `first_prompt`, `git_branch`, `cwd`.
25
+
26
+ ## Reading Session Messages
27
+
28
+ ```ruby
29
+ # Full conversation
30
+ messages = ClaudeAgentSDK.get_session_messages(session_id: 'abc-123-...')
31
+ messages.each { |msg| puts "[#{msg.type}] #{msg.message}" }
32
+
33
+ # Paginate
34
+ ClaudeAgentSDK.get_session_messages(session_id: 'abc-123-...', offset: 10, limit: 20)
35
+ ```
36
+
37
+ Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (raw API hash).
38
+
39
+ ## Renaming a Session
40
+
41
+ ```ruby
42
+ ClaudeAgentSDK.rename_session(
43
+ session_id: '550e8400-e29b-41d4-a716-446655440000',
44
+ title: 'My refactoring session',
45
+ directory: '/path/to/project' # optional
46
+ )
47
+ ```
48
+
49
+ ## Tagging a Session
50
+
51
+ ```ruby
52
+ ClaudeAgentSDK.tag_session(session_id: '550e8400-...', tag: 'experiment')
53
+ ClaudeAgentSDK.tag_session(session_id: '550e8400-...', tag: nil) # clear
54
+ ```
55
+
56
+ Tags are Unicode-sanitized before storing.
57
+
58
+ ## Deleting a Session
59
+
60
+ ```ruby
61
+ # Hard-delete (removes the JSONL file permanently)
62
+ ClaudeAgentSDK.delete_session(
63
+ session_id: '550e8400-...',
64
+ directory: '/path/to/project' # optional
65
+ )
66
+ ```
67
+
68
+ ## Forking a Session
69
+
70
+ ```ruby
71
+ # Fork into a new branch with fresh UUIDs
72
+ result = ClaudeAgentSDK.fork_session(
73
+ session_id: '550e8400-...',
74
+ title: 'Experiment branch' # optional, auto-generated if omitted
75
+ )
76
+ puts result.session_id # UUID of the new forked session
77
+
78
+ # Partial fork — fork up to a specific message
79
+ ClaudeAgentSDK.fork_session(
80
+ session_id: '550e8400-...',
81
+ up_to_message_id: 'message-uuid-here'
82
+ )
83
+ ```
84
+
85
+ > Session mutations use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They are safe to call while the session is open in a CLI process. `fork_session` uses `O_CREAT | O_EXCL` to prevent race conditions.
86
+
87
+ ## Resuming at a Specific Message
88
+
89
+ `resume_session_at` truncates the resumed conversation to messages up to **and including** the assistant message with the given UUID — useful for rewriting history from a known point or branching exploration without forking the session file. The flag rides on top of `resume`, so the original session ID is preserved; only the in-memory history loaded for the new turn is shortened.
90
+
91
+ ```ruby
92
+ ClaudeAgentSDK.query(
93
+ prompt: 'Try a different approach',
94
+ options: ClaudeAgentSDK::ClaudeAgentOptions.new(
95
+ resume: '550e8400-...',
96
+ resume_session_at: 'assistant-message-uuid-from-history'
97
+ )
98
+ ) { |message| }
99
+ ```
100
+
101
+ `resume_session_at` requires `resume`; the SDK raises `ArgumentError` from `CommandBuilder` when this constraint is violated, matching the underlying CLI's validation but surfacing it synchronously in the caller's stack.