OpenAI codecs
@ably/ai-transport/openai ships two codecs for the OpenAI Responses API, and which one you need depends on whether you work with a transport or a session.
ResponsesCodec is the wire codec: an encoder and a decoder, and nothing else. It turns a ResponseStreamEvent stream into Ably messages and back. The transports take it.
ResponsesSessionCodec is the session codec: the wire codec plus the reducer that merges events into a projection, the getMessages function the conversation tree calls, and the well-known input factories. Every session takes this one, and a wire codec does not satisfy it.
Both are single values rather than factories, so you pass the value itself and never call it. Neither takes type parameters, and one instance serves every session in the process.
The wire codec
1
2
3
4
5
// Client code. The transports want the wire codec.
import { createClientTransport } from '@ably/ai-transport';
import { ResponsesCodec } from '@ably/ai-transport/openai';
const transport = createClientTransport({ channel, codec: ResponsesCodec });Properties
adapterTagString or UndefinedcreateEncoder(channel, options?) => Encoder<OpenAIInput, OpenAIOutput>createDecoder() => Decoder<OpenAIInput, OpenAIOutput>See WireCodec for the generic interface the wire codec satisfies.
The session codec
On the agent, bind it to the session:
1
2
3
4
5
6
7
8
9
// Agent code.
import { createAgentSession } from '@ably/ai-transport';
import { ResponsesSessionCodec } from '@ably/ai-transport/openai';
const session = createAgentSession({
client: ably,
channelName: invocation.sessionName,
codec: ResponsesSessionCodec,
});On the client, the same value goes to the React provider:
1
2
3
4
5
6
7
8
'use client';
import { ClientSessionProvider } from '@ably/ai-transport/react';
import { ResponsesSessionCodec } from '@ably/ai-transport/openai';
<ClientSessionProvider channelName="conversations:demo" codec={ResponsesSessionCodec}>
<Chat />
</ClientSessionProvider>You rarely call the methods below yourself. A session calls init, fold, createEncoder, createDecoder, and getMessages for you. The methods you do call are createUserMessage, to send a user turn, and the three tool factories, to answer a tool call from the client.
Properties
Everything the wire codec has, plus the reducer, getMessages, and the input factories.
init() => OpenAIProjectionOpenAIProjection.fold(state, event, meta) => OpenAIProjectionOpenAISessionInput or OpenAIOutput into the projection.createEncoder(channel, options?) => Encoder<OpenAISessionInput, OpenAIOutput>createDecoder() => Decoder<OpenAISessionInput, OpenAIOutput>getMessages(projection) => CodecMessage<OpenAIMessage>[]{ codecMessageId, message } pairs from an OpenAIProjection.createUserMessage(message: OpenAIMessage) => OpenAISessionInputOpenAIMessage as the UserMessage input variant.createRegenerate(target, parent) => OpenAISessionInputRegenerate input targeting an assistant message.createToolResult(codecMessageId, payload) => OpenAISessionInputToolResult input addressed at the assistant message that holds the function_call. Payload keyed by call_id.createToolResultError(codecMessageId, payload) => OpenAISessionInputToolResultError input for a tool that failed. Payload keyed by call_id.createToolApprovalResponse(codecMessageId, payload) => OpenAISessionInputToolApprovalResponse input answering a gated call. Payload keyed by call_id.Tool payloads
The three tool factories take a payload keyed by OpenAI's snake_case call_id, taken from the function_call being answered. The Vercel codec uses toolCallId for the same purpose, so the two are not interchangeable.
call_idStringcall_id of the function_call this result answers.outputResponses.ResponseInputItem.FunctionCallOutput['output']function_call_output.output shape, so it reaches the model unchanged.call_idStringcall_id of the function_call that failed.messageStringfunction_call_output.output, so it becomes the output the model reads on the next turn.call_idStringcall_id of the gated function_call.approvedBooleanreasonStringAn approval records a decision and produces no output, so the agent runs an approved call server-side when the run resumes. A denial needs no server execution, because the reducer merges in a rejection function_call_output on the client. Conversation helpers covers reading that state back.
Types
role'user' | 'assistant'itemsOpenAIItem[]function_call.toolCallStatesRecord<string, OpenAIToolCallState>call_id. Present only when the message holds at least one gated or client-executed tool call, because OpenAI's item model has no field for either.OpenAIItemResponseOutputMessage | ResponseReasoningItem | ResponseFunctionToolCall | ResponseInputItem.FunctionCallOutput | ResponseInputItem.Message/responses unchanged.approval'pending' | 'approved' | 'denied'result'ok' | 'failed'nameStringargumentsStringreasonStringtype'tool-approval-request'call_idStringcall_id of the function_call this approval gates.nameStringfunction_call finishes streaming.argumentsStringfunction_call.OpenAIInputOpenAIMessageInput | OpenAIRegenerateInput | OpenAIItemInput | OpenAIApprovalInputai-input wire, as the wire codec sees them. Discriminated on kind.OpenAISessionInputUserMessage<OpenAIMessage> | Regenerate | ToolResult<OpenAIToolResultPayload> | ToolResultError<OpenAIToolResultErrorPayload> | ToolApprovalResponse<OpenAIToolApprovalResponsePayload>send accepts, and what the factories above return.OpenAIOutputResponseStreamEvent | { type: 'function_call_output', item } | ToolApprovalRequestEventai-output wire. The codec passes OpenAI's own ResponseStreamEvent through, and adds two events of its own: function_call_output, because a Responses stream never carries a tool's output, and tool-approval-request, because the Responses API has no equivalent.The event inventory is total, so an event outside it throws at the encoder rather than being dropped. An agent that enables a hosted tool (web or file search, code interpreter, image generation, MCP, custom tools) or audio must filter those events out of the stream before piping it, as Scope and trade-offs describes.
OpenAIProjectionOpenAIProjectiongetMessages to get { codecMessageId, message } pairs.The well-known input variants (UserMessage, Regenerate, ToolResult, ToolResultError, ToolApprovalResponse) are documented on the Codec reference page.
Example
Decode a single Ably message and merge the resulting events into a fresh projection. Merging needs the session codec, because only that codec has init, fold and getMessages. ReducerMeta.serial is required and carries ordering context only, so the reducer never treats it as a replay marker.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { ResponsesSessionCodec } from '@ably/ai-transport/openai';
const decoder = ResponsesSessionCodec.createDecoder();
let projection = ResponsesSessionCodec.init();
channel.subscribe((message) => {
if (!message.serial) return; // live channel-subscribe messages always carry one
const { inputs, outputs } = decoder.decode(message);
for (const event of inputs) {
projection = ResponsesSessionCodec.fold(projection, { direction: 'input', event }, { serial: message.serial });
}
for (const event of outputs) {
projection = ResponsesSessionCodec.fold(projection, { direction: 'output', event }, { serial: message.serial });
}
render(ResponsesSessionCodec.getMessages(projection).map((entry) => entry.message));
});Read next
- Conversation helpers:
toResponsesInputand the correlation readers the agent loop uses. - OpenAI Responses: how the codec fits an agent, including the tool and approval flow.
- Codec: the generic
Codecinterface and the well-known input variants.