stream = anthropic.messages.stream(
max_tokens: 1024,
messages: [{role: :user, content: "Say hello there!"}],
model: :"claude-sonnet-5-5"
)
stream.text.each do |text|
print(text)
end
putsclient.messages.stream returns a MessageStream which is an Enumerable that emits events and accumulates messages.
The HTTP stream will be cancelled when the MessageStream's events are exhausted, or if you call break while consuming the stream. You can also close it prematurely by calling stream.close.
See examples of streaming helpers in action in:
examples/messages_stream.rb- Basic streamingexamples/messages_stream_advanced.rb- Advanced streaming patternsexamples/thinking_stream.rb- Thinking events streamingexamples/tools_stream.rb- Tool use streamingexamples/citations_stream.rb- Document citations streamingexamples/text_stream.rb- Text streamingexamples/web_search_stream.rb- Web search streaming
Iterate over just the text deltas in the stream:
stream.text.each do |text|
print(text)
end
putsAborts the request.
Blocks until the stream has been read to completion.
Blocks until the stream has been read to completion and returns the accumulated Message object.
Blocks until the stream has been read to completion and returns all text content blocks concatenated together.
Returns the HTTP response headers from the streaming request. Provides access to rate limit information, request IDs, and other metadata.
Returns the request-id / anthropic-workspace-id response header from the streaming request, or nil if absent.
Returns the HTTP status code from the streaming request.
The events listed here are just the event types that the SDK extends, for a full list of the events returned by the API, see these docs.
require "anthropic"
stream = anthropic.messages.stream(
max_tokens: 1024,
messages: [{role: :user, content: "Say hello there!"}],
model: :"claude-sonnet-5-5"
)
stream.each do |event|
case event
when Anthropic::Streaming::TextEvent
print(event.text)
when Anthropic::Streaming::ContentBlockStopEvent
print("\n\ncontent block finished accumulating #{event.content_block}")
end
end
puts
# you can still get the final accumulated message outside of
# the context manager, as long as the entire stream was consumed
# inside of the context manager
accumulated = stream.accumulated_message
puts("accumulated message: #{accumulated.to_json}")This event is yielded whenever a text content_block_delta event is returned by the API & includes the delta and the accumulated snapshot, e.g.
when Anthropic::Streaming::TextEvent
event.text # " there"
event.snapshot # "Hello, there"This event is yielded whenever a JSON content_block_delta event is returned by the API & includes the delta and the accumulated snapshot, e.g.
when Anthropic::Streaming::InputJsonEvent
event.partial_json # ' there"'
event.snapshot # '{"message": "Hello, there"'The event is yielded when a full Message object has been accumulated.
when Anthropic::Streaming::MessageStopEvent
event.message # MessageThe event is yielded when a full ContentBlock object has been accumulated.
when Anthropic::Streaming::ContentBlockStopEvent
event.content_block # ContentBlockThe event is yielded when a full Message object has been accumulated.
when Anthropic::Streaming::MessageStopEvent
event.message # Messagewhen Anthropic::Streaming::ContentBlockStopEvent
event.citation # Citation
event.snapshot # Array[Citation] including all of the accumulated citations so farwhen Anthropic::Streaming::ThinkingEvent
event.thinking # String
event.snapshot # The accumulated thinking so farwhen Anthropic::Streaming::SignatureEvent
event.signature # Signature from a signature_delta eventInput schemas define structured data classes for tools and structured outputs using Anthropic::BaseModel.
class GetWeatherInput < Anthropic::BaseModel
required :location, String, doc: "The city and state, e.g. San Francisco, CA"
optional :unit, Anthropic::EnumOf[:celsius, :fahrenheit], doc: "Temperature unit"
end
class GetWeather < Anthropic::BaseTool
doc "Get the current weather in a given location"
input_schema GetWeatherInput
def call(input) = ...
endrequired :name, Type- Required fieldoptional :name, Type, doc: "description"- Optional fieldrequired :name, Type, doc: "description", nil?: true- Required but nullable
String - Text values with optional validation:
required :name, String, doc: "User's full name"
required :code, String, min_length: 3, doc: "Airport code (e.g., JFK)"Integer - Whole numbers:
required :age, Integer, doc: "Age in years"
optional :max_stops, Integer, nil?: trueFloat - Decimal numbers:
required :price, Float, doc: "Price in USD"
required :latitude, Float, doc: "GPS latitude"Anthropic::Boolean - true/false values:
optional :flexible_dates, Anthropic::Boolean
optional :nonstop_only, Anthropic::Boolean, nil?: trueAnthropic::EnumOf[:option1, :option2] - Limited set of values:
required :cabin, Anthropic::EnumOf[:economy, :premium, :business, :first]
required :unit, Anthropic::EnumOf[:celsius, :fahrenheit], nil?: trueAnthropic::ArrayOf[Type] - Arrays of any supported type:
required :passengers, Anthropic::ArrayOf[Passenger, nil?: true]
optional :preferred_airlines, Anthropic::ArrayOf[String], nil?: true
required :coordinates, Anthropic::ArrayOf[Float]Anthropic::UnionOf[Type1, Type2] - Multiple possible types:
required :origin, Anthropic::UnionOf[String, Airport] # Either "JFK" or Airport object
required :value, Anthropic::UnionOf[Integer, String] # Either 42 or "42"Nested Models - Other Anthropic::BaseModel subclasses:
class Airport < Anthropic::BaseModel
required :code, String
required :name, String
end
class FlightSearch < Anthropic::BaseModel
required :origin, Airport # Nested object
optional :alternate, Airport, nil?: true
endAdd nil?: true to make any field accept nil values:
class Passenger < Anthropic::BaseModel
required :name, String # Cannot be nil
required :seat, Anthropic::EnumOf[:window, :aisle], nil?: true # Can be nil
optional :bags, Integer, nil?: true # Optional AND nullable
endThis works with ArrayOf[...] as well as HashOf[...]
Anthropic::ArrayOf[String, nil?: true]You can also use Anthropic::UnionOf[..., NilClass] to construct an arbitrary nilable type
Anthropic::UnionOf[String, NilClass]Usage patterns:
required :field, Type- Must be provided, cannot be nilrequired :field, Type, nil?: true- Must be provided, but can be niloptional :field, Type- May be omitted, cannot be nil if providedoptional :field, Type, nil?: true- May be omitted or be nil
Tools let Claude call external functions. There are three approaches:
Handle tool calls yourself:
message = client.messages.create(
model: "claude-sonnet-5-5",
max_tokens: 1024,
messages: [user_message],
tools: [GetWeather.new]
)
if message.stop_reason == :tool_use
tool = message.content.grep(Anthropic::Models::ToolUseBlock).first
# Execute your tool logic here
# Then send tool_result back to continue conversation
endGet tool input as it streams:
stream = client.messages.stream(
model: "claude-sonnet-5-5",
max_tokens: 1024,
tools: [GetWeather.new],
messages: [...]
)
stream.each do |event|
case event
in Anthropic::Streaming::InputJsonEvent
puts(event.partial_json) # Incremental JSON
puts(event.snapshot) # Full JSON so far
else
end
end
# Get parsed tool calls
tool_uses = stream.accumulated_message.content.grep(Anthropic::ToolUseBlock)Automatically execute tools and continue conversation:
class CalculatorInput < Anthropic::BaseModel
required :lhs, Float
required :rhs, Float
required :operator, Anthropic::InputSchema::EnumOf[:+, :-, :*, :/]
end
class Calculator < Anthropic::BaseTool
doc "i am a calculator and i am good at math"
# you must specify the input schema to the tool
input_schema CalculatorInput
# you can override `#parse` to pre-process the tool call arguments prior to `#call`
def parse(value) = value
def call(expr)
expr.lhs.public_send(expr.operator, expr.rhs)
end
end
tool = Calculator.new
# Automatically handles tool execution loop
client.beta.messages.tool_runner(
model: "claude-sonnet-5-5",
max_tokens: 1024,
messages: [{role: "user", content: "What's 15 * 7?"}],
tools: [tool]
).each_message { puts _1.content }Process each message after Claude responds. Good for filtering content or triggering actions:
runner.each_message do |message|
text_blocks = message.content.grep_v(Anthropic::Models::Beta::BetaToolUseBlock)
puts "Claude says: #{text_blocks.first&.text}" unless text_blocks.empty?
endSee text and tool calls as they happen in real-time:
runner.each_streaming do |stream|
stream.each { |event| print(event.text) if event.respond_to?(:text) }
puts "\nFinal: #{stream.accumulated_text}"
endGet one message at a time for fine-grained control:
loop do
message = runner.next_message
break if message.nil?
# Process message and decide whether to continue
endAdd your own messages to guide the conversation:
runner.each_message do |message|
if message.content.any? { |block| block.text&.include?("let me") }
runner.feed_messages({role: :user, content: "Say 'allow me' instead"}, ...)
end
endGet the current parameter of the runner. You can inspect #params to see what was the previous request parameter that resulted in the current response.
And by modifying #params, you can customize the next request parameters as well.
runner.each_message do |message|
puts runner.params
runner.params.update(max_tokens: 9999)
endChanging tools: in the middle of a conversation misses the prompt cache for everything sent so far. With the inline-tools-2026-09-15 beta a tool change is sent as a message instead, and #add_tools and #remove_tools do that for you. tools: is sent exactly as you first passed it on every request.
runner = client.beta.messages.tool_runner(
model: "claude-sonnet-5-5",
max_tokens: 1024,
betas: ["inline-tools-2026-09-15"],
messages: [{role: "user", content: "Find a slot for a 30 minute call with Sam next week."}],
tools: [GetTime.new]
)
find_free_slots = FindFreeSlots.new
runner.each_message do |message|
runner.add_tools(find_free_slots) if calendar.just_connected?
runner.remove_tools(find_free_slots) if calendar.just_disconnected? # or by name: "find_free_slots"
end#add_tools takes one or more of what tools: holds, and sends the whole definition either way.
- An
Anthropic::BaseToolcan be called from the request that carries its definition, and replaces a tool of the same name straight away: a call the model has already made in the message you're handling runs the new one. - A raw definition is sent as given and never run by the tool runner. That covers server tools such as
{type: "web_search_20250305", name: "web_search"}and client tools with nothing to run behind them (a call to one gets the "not found" error result). It also stops a tool of the same name from running. - An
mcp_toolsetdefinition also needs its server inmcp_servers:, which#add_toolsdoesn't change.
#remove_tools takes one or more tools, or their names. A removed tool stops being run straight away, so a call the model has already made to it in the message you're handling gets the "not found" error result. It stays removed until you pass it to #add_tools again. Removing a server tool only tells the model.
A few things to know:
- Changes made while you're handling a message go out together as one
role: :systemmessage right after that turn's tool results, in the order you made them. Changes made before the first request follow the initial messages. - After a paused turn (
pause_turn) the turn is sent back as it came, and the changes go out with the request after that. - Changes still waiting when the run ends are never sent, also when
#compact_before_next_turncompacts after that last turn. - A change made on the same turn as
#compact_before_next_turngoes out with the compaction request, and the tools you added or removed stay that way for the tool runner after the compaction. - In the rare case where a compaction response comes back without
tool_changeseven though the summarized messages added or removed tools, the model goes back to the tools intools:and the tool runner does not detect it. Call#add_tools/#remove_toolsagain after that compaction if you need the change restored. - The runner doesn't add the beta for you, so pass
betas: ["inline-tools-2026-09-15"]. - Changing
tools:through#paramsstill works, but misses the prompt cache. - Use either these methods or
tool_addition/tool_removalblocks you append yourself for a given tool, not both.
Let the conversation finish, then process all messages at once:
first_msg = runner.next_message
runner.feed_messages({role: :user, content: "Be more confident"}) if needed
all_messages = runner.run_until_finishedWith the compact-2026-09-04 beta you decide when a conversation is compacted: a request with the compaction param returns a single compaction block, which then replaces the messages it summarizes. In a tool runner, call #compact_before_next_turn and the runner does this for you:
runner = client.beta.messages.tool_runner(
model: "claude-sonnet-5-5",
max_tokens: 1024,
betas: ["compact-2026-09-04"],
messages: [{role: "user", content: "Find every page that mentions rate limits."}],
tools: [search_docs]
)
runner.each_message do |message|
runner.compact_before_next_turn if message.usage.input_tokens > 100_000
endThe call only schedules the compaction. Once the current turn has finished, including any tool calls, the runner requests a summary, replaces its messages with the compaction response the API returns, and carries on. A turn that was paused (pause_turn) is resumed and finished first. If the current turn is the last one, the runner compacts and then stops. If you call it before the first request, the compaction is the first request.
You get the compaction response like any other message (#each_message and #each_streaming yield it, #next_message returns it), and it doesn't count towards max_iterations. It has stop_reason: :compaction, the summary is in message.content.first.content, and its top-level usage.input_tokens and usage.output_tokens are 0: what the compaction cost is in usage.iterations. Calling #compact_before_next_turn while handling that message does nothing, so a threshold like the one above doesn't compact twice.
#compact_before_next_turn takes the same config as the compaction: param of messages.create, for example to give your own summarization instructions:
runner.compact_before_next_turn({type: :summarize, instructions: "Keep the page URLs found so far."})A few things to know:
- Calling it again before the compaction runs replaces the pending one.
- The runner doesn't add the beta for you, so pass
betas: ["compact-2026-09-04"]. context_management,stop_sequences, atool_choicethat forces a tool (anyortool) and the output format (output_config[:format], also in each entry offallbacks, or the deprecatedoutput_format) are left out of the compaction request, because the API doesn't accept them together withcompaction, and are sent again afterwards.#compact_before_next_turnraises anArgumentErrorifcontext_managementhas acompact_*edit, and so does adding one while a compaction is pending.- While you're handling the compaction response in an
#each_messageor#each_streamingblock,#feed_messagesand assigningrunner.params = …with differentmessagesraise anArgumentError, because the compaction response is about to replace the messages. Other params can still be changed. To change the messages in a way the runner notices during a run, assignrunner.params = …or use#feed_messages; editing the array in place is not detected while a compaction is in progress. - If the API returns no summary, the runner prints a warning and keeps the messages as they are.
- If the run ends on a turn that was cut short with tool calls that never ran (
stop_reason: :max_tokens, for example), the pending compaction is skipped with a warning. It is also skipped if the run stops atmax_iterationsor youbreakout of the block. #next_messageonly returns the last message once the run has finished. Call#compact_before_next_turnand then#next_messageonce more to compact after it.- The
compactionparam itself can't be set on a tool runner, because every request in the loop would compact again.
doc and input_schema declare a tool's description and input_schema, and its name is derived from the class name. Every other property of the tool definition — such as strict, cache_control, defer_loading, allowed_callers, eager_input_streaming or input_examples — is declared with tool_options. The options are sent whenever the tool is passed in tools:, whether to create, stream or the tool runner:
class Calculator < Anthropic::BaseTool
doc "i am a calculator and i am good at math"
input_schema CalculatorInput
# guarantee schema-conformant tool calls, and put a prompt-caching breakpoint after this tool
tool_options strict: true, cache_control: {type: :ephemeral}
def call(expr)
expr.lhs.public_send(expr.operator, expr.rhs)
end
endOptions accumulate — calling tool_options again merges into what was already declared, and a subclass starts from its superclass's options — so shared settings can live on a common base class:
class StrictTool < Anthropic::BaseTool
tool_options strict: true
end
class Calculator < StrictTool
doc "i am a calculator and i am good at math"
input_schema CalculatorInput
tool_options cache_control: {type: :ephemeral} # sent with strict: true as well
# ...
endSee strict tool use for what strict: true guarantees and the schema features it supports.
With the inline-tools-2026-09-15 beta, a tool_addition block in a role: :system message can define a tool by value. Its definition takes the same forms as an entry of tools:: it is sent as the same tool definition, and the tool's tool_use blocks get parsed the same way:
message = client.beta.messages.create(
model: "claude-sonnet-5-5",
max_tokens: 1024,
betas: ["inline-tools-2026-09-15"],
tools: [GetWeather.new],
messages: [
{role: :user, content: "What's 15 * 7?"},
{role: :system, content: [{type: :tool_addition, tool: {type: :tool_definition, definition: Calculator.new}}]}
]
)The tool runner does not call a tool defined this way; it only calls the tools passed in tools:.
See example files: examples/tools*.rb and examples/auto_looping*.rb
Structured outputs allow you to constrain Claude's responses to follow a specific JSON schema, making it easier to extract structured data. Use the output_config parameter with a BaseModel class to define the expected output format.
class FamousNumber < Anthropic::BaseModel
required :value, Float
optional :reason, String, doc: "why is this number mathematically significant?"
end
class Output < Anthropic::BaseModel
doc "some famous numbers"
required :numbers, Anthropic::ArrayOf[FamousNumber], min_length: 3, max_length: 5
end
message = anthropic.messages.create(
model: "claude-sonnet-5-5",
max_tokens: 1024,
messages: [{role: "user", content: "give me some famous numbers"}],
output_config: {format: Output}
)
# Access the parsed output directly
message.parsed_output
# => #<Output numbers=[#<FamousNumber value=3.14159... reason="Pi is the ratio...">...]>The output_config parameter accepts:
{format: MyModel}- Pass aBaseModelclass directly{format: {type: :json_schema, schema: {...}}}- Pass a raw JSON schema
When using output_config, the response is automatically parsed and validated against your model:
The Message object provides a convenience method:
message.parsed_output # Returns the parsed model instance or nilYou can also access the parsed content directly from the text block:
text_block = message.content.first
text_block.parsed # Returns the parsed model instanceIf parsing fails, parsed will contain {error: "error message"}.
Structured outputs work with streaming. The parsed output is available after the stream completes:
stream = anthropic.messages.stream(
model: "claude-sonnet-5-5",
max_tokens: 1024,
messages: [{role: "user", content: "give me some famous numbers"}],
output_config: {format: Output}
)
# Stream the raw text as it arrives
stream.text.each { |text| print(text) }
# Get the parsed output from the accumulated message
stream.accumulated_message.parsed_output
# => #<Output numbers=[...]>The count_tokens method also supports output_config:
result = anthropic.messages.count_tokens(
model: "claude-sonnet-5-5",
messages: [{role: "user", content: "give me some famous numbers"}],
output_config: {format: Output}
)See example files:
examples/structured_output.rb- Basic structured outputexamples/structured_output_stream.rb- Streaming structured output