Install openai and the optional async-websocket gem. The multiplex workflow
uses OpenAI::Responses::Session; versions with only client.responses.connect
can use the raw connection patterns below.
gem "openai"
gem "async-websocket"Responses WebSocket mode connects to /v1/responses. It uses Responses events;
it is distinct from Realtime sessions. See the
platform guide
for the service contract.
The workflow example demonstrates
four patterns with one reader and no background worker threads. Keep
OPENAI_API_KEY in your environment. These commands make real API calls:
bundle exec ruby examples/responses/websocket_workflows.rb tools
bundle exec ruby examples/responses/websocket_workflows.rb multiplex
bundle exec ruby examples/responses/websocket_workflows.rb stored
bundle exec ruby examples/responses/websocket_workflows.rb statelessSet OPENAI_RESPONSES_MODEL to override gpt-5.2 and
OPENAI_RESPONSES_TIMEOUT to change the 60-second overall deadline. The example
prints only a completion marker, not prompts, model output, or server errors.
The default workflow is multiplex. The stored workflow requires an account
that allows response storage; use stateless with store=false/ZDR.
client.responses.connect do |connection|
connection.response.create(model: "gpt-5.2", input: "Say hello.")
connection.each do |event|
case event.type.to_s
when "response.completed"
# Save event.response.id and handle event.response.output here.
break
when "response.failed", "response.incomplete", "error"
raise "Responses operation did not complete."
end
end
endUse the complete example for EOF detection: a connection ending before every
expected terminal event is not success. connect requires a block and closes
on block exit. Connection options belong in connect; model/input fields belong
in connection.response.create.
Known events are decoded into generated models on a best-effort basis. New
event types arrive as OpenAI::Responses::UnknownServerEvent. Preserve or handle
them explicitly where your application needs them. Do not assume every event
has a response, text delta, or lane ID.
The default Responses transport accepts max_message_bytes,
max_wire_message_bytes, and max_message_frames in transport_options.
Each is an optional positive integer; omitted or nil keeps its unlimited default. Choose values
for your application's legitimate largest message, including images and tool
results. These are caller budgets, not API limits.
client.responses.connect(
transport_options: {max_message_bytes: 64 * 1024 * 1024, max_message_frames: 4_096}
) do |connection|
connection.response.create(model: "gpt-5.2", input: "Hello")
while (event = connection.receive)
puts event.type
end
endThe decoded byte budget (max_message_bytes) covers the payload of each complete
message after decompression, including all its fragments. The optional wire budget
(max_wire_message_bytes) covers data payload bytes before decompression across those
fragments, excluding frame headers and control frames. A wire budget alone does
not impose a decoded byte limit. Oversized frame headers are rejected
before the advertised payload is read (with up to 125 bytes allowed for a legal
interleaved control frame). The frame budget counts text, binary and continuation
frames, including empty continuations; ping/pong and close frames don't count.
Set both when you need to bound both payload and frame-buffer memory.
Counters reset at the end of each message.
To use WebSocket compression with independently bounded wire and decoded sizes:
limits = {
max_wire_message_bytes: 8 * 1024 * 1024,
max_message_bytes: 64 * 1024 * 1024,
max_message_frames: 4_096
}
client.responses.connect(transport_options: limits) do |connection|
connection.response.create(model: "gpt-5.2", input: "Hello")
while (event = connection.receive)
puts event.type
end
endThe server can still decline compression, in which case both byte budgets apply
to the uncompressed message. Existing byte-only configurations continue to
decline compression, as before; no-limit and frame-only configurations retain
their normal compression negotiation.
Exceeding any chosen limit aborts the socket, releases its pool slot, and
raises OpenAI::Errors::ResponsesConnectionError; no requests are replayed.
Managed sessions accept the same transport_options. An injected transport
still receives its options unchanged and owns their meaning.
OpenAI::Responses::Session.open adds routed lanes and final-response collection
using the same authentication, custom headers, and connection options as
client.responses.connect. With OPENAI_API_KEY set, this complete example
opens a session and closes its reader and transport when the block exits:
require "openai"
limits = OpenAI::Responses::SessionLimits.new(
max_lanes: 8,
max_events_per_lane: 128,
max_events: 512,
max_bytes_per_lane: 16 * 1024 * 1024,
max_bytes: 32 * 1024 * 1024,
max_response_bytes: 64 * 1024 * 1024
)
OpenAI::Responses::Session.open(
client: OpenAI::Client.new,
limits: limits,
request_options: {extra_headers: {"X-Application-Request" => "example"}}
) do |session|
lane = session.lane("conversation")
lane.send_event(type: "response.create", model: "gpt-5.2", input: "Say hello.")
response = lane.get_final_response
puts response.status
# Supply previous_response_id: response.id explicitly for continuation.
endThese required budgets are application choices, not service limits or changes
to raw connection defaults. The default and all detached lane IDs count toward max_lanes until reconnect. Queue
budgets apply per lane and across the session; byte counts measure serialized
event data, not total process memory. max_response_bytes bounds cumulative
compatible event bytes retained for collection. Choose budgets for legitimate
large output, including images and tool results. Queue overflow closes the
session's connection; collection overflow leaves raw events and other lanes usable.
OpenAI::Responses::IncrementalResponse builds provisional output from events
you feed it. Use a separate instance for each lane. It does not read, close, or
write to the connection. Tools remain data. Its typed output is a truthful provisional prefix; its admitted terminal event
stays exact, including an absent output field. Call reset to discard only
the preview's state.
MCP call argument deltas update an observed MCP call item. The arguments-done event replaces its provisional arguments. These updates preserve the item's other fields and do not execute the tool.
Session limits govern the session's queues and final-response collector.
They do not bound a separate IncrementalResponse. Choose a finite budget
for the encoded events that you feed to each preview and a finite lifetime.
Check the complete event before every add, including unknowns, errors, and
terminal events. Stop feeding when an event would exceed the budget. Never
report a completed preview for a terminal event that you did not admit.
output returns a detached typed prefix during provisional progress. Sparse or
incomplete records stop that prefix; higher wire indices never shift into lower
positions. Use detailed_snapshot for sparse facts and response metadata:
preview.add(event)
facts = preview.detailed_snapshot
facts&.fetch(:output)&.each do |row|
puts [row.fetch(:output_index), row.fetch(:item)].inspect
endThe detailed Hash uses symbol keys and JSON values. Its response excludes the
separately indexed output, while stream_id, response_id, terminal_type and
phase describe the observations. Output rows retain actual output_index
values. Nested content, summary, commands and annotation rows retain their
actual indices. output_text joins only observed message text in wire-index
order; it excludes reasoning and refusals. Missing item or part types stay missing; this view is not a
complete Response. Unknown fields and absent, null and empty metadata remain
distinct. A null annotation stays at its detailed index and stops the typed
annotation prefix. The view is nil before observations and when the preview is
unavailable. Terminal views use only the supplied terminal event, and typed
output remains nil after a terminal.
Shell output needs separate views. The item's output stays the last supplied
full-item flat array. Detailed content rows use command_index: they contain
observed stdout/stderr, or {output: [...]} after done. That array preserves all
chunks for that one command, including an empty array. It does not imply a flat
position, a missing exit code or an outcome. A later full item or terminal
replaces provisional command facts. A missing companion call leaves command
bounds unknown.
Both views copy their results; the detailed view also sorts observed indices. Use raw deltas for per-event progress and take snapshots at useful boundaries.
The runnable preview example sets a decoded per-message cap, uses the session's separate limits, and applies a 4 MiB cumulative preview budget with a 15-second lifetime:
bundle exec ruby examples/responses/websocket_workflows.rb previewpreview_lane uses the model's deep_to_h for known events and the raw
to_h for unknown events. It counts the UTF-8 bytes in the encoded JSON,
including nested and unknown fields. These are encoded event bytes, not the
original wire bytes or a bound on total heap memory. It also checks a
monotonic deadline while draining queued events; the
Async timeout interrupts an idle wait. Choose budgets large enough for legitimate
image, audio, tool, or other large output in your application.
The result has outcome: :terminal, :budget_exceeded, or :timeout.
Only :terminal includes terminal_event, the exact admitted completion,
failure, incomplete, or error event. Inspect that event's type and status.
A stop after a read can include last_received_event. With :budget_exceeded,
the event was not admitted to the preview. With :timeout, it may already
have been admitted; a terminal event can cross the deadline during preview
assembly. This field reports what was read and does not guarantee that the
event is safe to process again. Only outcome: :terminal reports a completed
preview.
Raw events, lane finals and other lanes keep their normal SDK contracts. A preview timeout or budget stop resets the preview; it does not close the lane, cancel the response, or choose a continuation for you. The outer session still closes when its owning block exits. EOF, transport failures and other cancellation propagate as usual.
Register lanes before sending and consume each response before creating another
on that lane. One Async task reads the physical connection; each lane has one
consumer, and all session operations use the owning Ruby thread. lane.receive
returns original typed or unknown events. lane.get_final_response consumes
remaining events and returns the current completed, failed, or incomplete response;
inspect its status. Collect a parent's final response before receiving its successor's
response.created, which starts accumulation for the successor. The helper does not
retain a history of completed responses; callers can retain the raw terminal events. Protocol errors raise OpenAI::Responses::RequestError with the
original event in error.event. Raw commands other than response.create leave
subsequent unsequenced, unscoped errors uncorrelated until explicit reconnect. Consuming
such an error during an active default response fails that lane; named lanes remain
usable. Errors received before the first raw submission retain ordinary create-retry
behavior, regardless of later commands sent before consuming them. Observe session.default for connection-level
errors and events with no registered lane. Closing a named lane discards its
queue and routes future events for that ID to the default lane. Closing a lane
does not cancel its server work. Its ID remains reserved until reconnect,
including after terminal events: steering can create automatic successors.
Continue using the same open lane for sequential responses. Closing the default
lane opts out of unclaimed events; it cannot be replaced before reconnect.
Recovery is explicit: session.reconnect(client: fresh_client, request_options: fresh_options, restore: callback) re-evaluates authentication
and custom headers. Old lane handles raise StateLostError; the callback
registers fresh lanes and sends application-selected restoration state. No
previous create is replayed. Choose stored IDs or full history as described in
reconnect guidance.
The tools workflow forces a call to one known, harmless application function.
After the response completes, it checks the function name and sends its output
using the original call_id. The next create uses previous_response_id and
only the new function_call_output item. It does not resend the previous input
on the same connection.
Tool execution belongs to your application. Validate arguments against your tool's contract and authorize effects before execution. Never execute generated code or dispatch arbitrary method names from a tool event. The example uses a fixed result and does not execute model-provided code.
stream_id routes events to a lane; previous_response_id selects conversation
history. They are independent. Reusing a lane without a previous response ID
starts a new response rather than continuing that lane's conversation.
The multiplex workflow writes two creates, then drains both lanes with one
reader. Events may interleave and the first terminal event does not finish the
whole operation. Keep a pending-lane set and retain each completed response ID.
Omitting stream_id uses the default lane; its events omit the field as well.
The example fails the whole operation on any error. Production applications can route a lane-scoped error to just that lane, while treating a connection-scoped error as affecting the connection. Track separate response IDs when multiple requests are outstanding within a lane; a lane alone is not a request ID.
The server executes same-lane requests in FIFO order without overlap. Named IDs contain 1–256 ASCII letters, digits, underscores, hyphens or periods; an empty string is invalid. The raw connection forwards IDs without enforcing this grammar. The optional session helper routes events into local queues and allows one consumed response at a time per lane; it does not schedule server work. Named-lane terminal events and request errors echo the ID; default-lane events omit it.
The service allows 16 active responses and queues additional creates. It accepts 32 distinct named IDs per connection; the default lane does not count. These are separate from the session's application budgets. Detaching a local lane does not reset the server's distinct-ID count.
To fork, send an existing response ID on a different lane. With store=false or ZDR,
wait for the fork's response.in_progress before advancing the source lane so
its parent remains available in the connection-local cache.
The server keeps recent response state in a connection-local cache. With
store=true, an older response may be loaded from persisted state. With
store=false or ZDR, an uncached ID returns previous_response_not_found.
A same-lane continuation returning a 4xx or 5xx evicts its referenced cached
parent; an errored cross-lane fork preserves the shared parent for the source
lane. Do not blindly retry the same parent after a cache miss; use the explicit
context-restoration approach below when needed.
For optional warmup, send connection.response.create(..., generate: false),
consume the terminal response, and save its ID. A later create can use that ID
as previous_response_id with new input. Warmup prepares state without model
output; the existing create helper forwards generate as an extra field.
With automatic compaction configured through context_management, continue
using the latest response ID and only new input items. Standalone
client.responses.compact(...) instead returns a compacted input window. Send
its complete output as input to a new WebSocket chain, omitting or nulling
previous_response_id; do not use the compaction object's ID as a response ID
or prune items from its output.
Both reconnect examples intentionally close a healthy connection after a completed turn, then open another connection. They do not catch a failed write and retry it.
- Stored: send only the new input plus the stored
previous_response_id. If the service cannot retrieve that response, the application must decide whether to reconstruct context and start a new chain. - Stateless: connection-local state is lost on reconnect. Retain the original
inputs and every output item, including reasoning and tool items. Request
include: ["reasoning.encrypted_content"]for reasoning-model replay. Send the retained history plus new input, omittingprevious_response_idon the new connection. Do not reduce history to displayed assistant text.
Retained history can contain sensitive data. Apply your application's retention policy; neither the SDK nor this example writes a recovery log. A full-context replay starts a new chain and is not an exactly-once retry of a failed turn.
Connections have a finite service lifetime (currently up to 60 minutes). Plan rotation and persistence at completed-turn boundaries. There is no automatic reconnect, rollover, or replay in this SDK connection.
An SDK request timeout bounds connection negotiation, not an established session's idle lifetime. The example wraps the complete workflow in an Async deadline on the owning thread:
require "async"
Sync do |task|
task.with_timeout(60) do
client.responses.connect do |connection|
# Send and read on this thread, inside the deadline.
end
end
endConnections enforce one owning thread and one active reader. Do not call close
from another thread or nest receive inside an active each. The Async deadline
can interrupt an idle network read on the owning thread; the SDK then aborts and
cleans up the connection. A timeout inside a transport operation can surface as
ResponsesConnectionError, rather than Async::TimeoutError. Application code
that does CPU-bound work without yielding needs its own cancellation strategy.
Exiting the block normally performs a close. An exception or poisoned connection causes an abort, avoiding a flush of uncertain buffered output. The peer may see an abrupt disconnect. This is local shutdown, not a server-side cancellation acknowledgment, and it does not guarantee that a response stopped executing.
ResponsesSendError#outcomeis:unknown: the server may have accepted the event. Stop using that connection; do not automatically resend it.- A transport read failure poisons the connection. A server
errorevent is delivered as an event and does not by itself poison it. Handleprevious_response_not_found,invalid_stream_id,websocket_stream_limit_reached, andwebsocket_connection_limit_reachedthrough the original error event. Observesession.defaultwhen using named session lanes so connection-scoped errors are not left unread. - Malformed JSON raises
ResponsesProtocolErrorwithout exposing the payload; the connection permits later reads. Decide whether continuing is appropriate for your application. - Raw
send_eventsupports generated client models or hashes, includingresponse.steer. There is noconnection.response.steerconvenience method. Steering accepts onlytype,previous_response_id, andinput, withoutstream_id. Accepted input is committed at the successor'sresponse.created. Ifresponse.steer.pendingrequires tool output or approval, fill itsrequired_inputstubs and send one create on the parent's lane with that parent ID. Reuse saved results; do not rerun tools or resend accepted input. - Follow the platform contract; do not send HTTP-only
streamorbackgroundflags just because generated models expose them. - X.509 workload identity and provider runtimes are not supported by this
entry point. A nonempty
request_options.extra_queryor nonzerorequest_options.max_retriesis rejected. Supported workload identity has a narrow pre-handshake 401 refresh; that is not live-session recovery. - If your client uses a configured HTTP transport, pass a compatible WebSocket
transport:with its own connection settings. The default WebSocket transport cannot carry over HTTP connection policies. It rejects that configuration before requesting credentials or opening a socket. Default HTTP clients and their copies keep using the default WebSocket transport. - Large events are valid. Plan memory and application backpressure for your workload without treating a fixed payload size as a protocol maximum.
bundle exec ruby -Itest test/openai/responses_websocket/workflows_test.rbThe tests use real loopback WebSockets and synthetic data, exercise the default Responses transport, and make no live API requests. They cover the four example patterns, interleaved terminal events, fragmented text, premature EOF, failures, idle-read deadlines, and a large event exceeding 32 MiB.