diff --git a/src/data/languages/languageData.ts b/src/data/languages/languageData.ts index 8ce54683ea..eee39146d0 100644 --- a/src/data/languages/languageData.ts +++ b/src/data/languages/languageData.ts @@ -44,7 +44,7 @@ export default { android: '1.2', }, aiTransport: { - javascript: '0.6', + javascript: '0.7', }, spaces: { javascript: '0.5', diff --git a/src/data/nav/aitransport.ts b/src/data/nav/aitransport.ts index e558d692b5..aa0a606be9 100644 --- a/src/data/nav/aitransport.ts +++ b/src/data/nav/aitransport.ts @@ -23,6 +23,10 @@ export default { name: 'Core SDK', link: '/docs/ai-transport/getting-started/core-sdk', }, + { + name: 'OpenAI', + link: '/docs/ai-transport/getting-started/openai', + }, { name: 'Vercel AI SDK', link: '/docs/ai-transport/getting-started/vercel-ai-sdk', @@ -86,6 +90,10 @@ export default { { name: 'Frameworks', pages: [ + { + name: 'OpenAI', + link: '/docs/ai-transport/frameworks/openai', + }, { name: 'Vercel AI SDK UI', link: '/docs/ai-transport/frameworks/vercel-ai-sdk-ui', @@ -226,6 +234,19 @@ export default { }, ], }, + { + name: 'OpenAI', + pages: [ + { + name: 'Codec', + link: '/docs/ai-transport/api/javascript/openai/codec', + }, + { + name: 'Conversation helpers', + link: '/docs/ai-transport/api/javascript/openai/conversation-helpers', + }, + ], + }, { name: 'Vercel', pages: [ diff --git a/src/pages/docs/ai-transport/api/errors.mdx b/src/pages/docs/ai-transport/api/errors.mdx index 5accdee759..d98044d1bb 100644 --- a/src/pages/docs/ai-transport/api/errors.mdx +++ b/src/pages/docs/ai-transport/api/errors.mdx @@ -109,7 +109,7 @@ agentSession.on('error', (error) => { console.error('Session error:', error); }); -const run = agentSession.createRun(invocation, { +const run = agentSession.createRun(invocation, {}, { signal: req.signal, onError(error) { console.error('Run error:', error); diff --git a/src/pages/docs/ai-transport/api/javascript/core/agent-session.mdx b/src/pages/docs/ai-transport/api/javascript/core/agent-session.mdx index c534202bf8..717adf6eaf 100644 --- a/src/pages/docs/ai-transport/api/javascript/core/agent-session.mdx +++ b/src/pages/docs/ai-transport/api/javascript/core/agent-session.mdx @@ -28,7 +28,7 @@ const session = createAgentSession({ }); await session.connect(); -const run = session.createRun(invocation, { signal: req.signal }); +const run = session.createRun(invocation, {}, { signal: req.signal }); await run.start(); ``` @@ -105,14 +105,16 @@ await session.connect(); ## Create a run -{`createRun(invocation: Invocation, runtime?: RunRuntime): AgentRun`} +{`createRun(invocation: Invocation, identity?: Partial, hooks?: RunHooks): OpenableRun`} -Create a new `AgentRun` for the input event named in the `Invocation`. Returns synchronously and publishes nothing to the session until [`AgentRun.start`](#run-start) is called. The run is registered for cancel routing immediately so early cancels fire the `abortSignal`. +Create a new run for the input event named in the `Invocation`. Returns synchronously and publishes nothing to the session until [`start`](#run-start) is called. The run is registered for cancel routing immediately so early cancels fire the `abortSignal`. + +Identity and behaviour are separate arguments. The second overrides the run's ids, and the third carries the abort signal and the hooks. On the normal one-request path there is no id to override, so the second argument is `{}`: ```javascript const invocation = Invocation.fromJSON(await req.json()); -const run = session.createRun(invocation, { signal: req.signal }); +const run = session.createRun(invocation, {}, { signal: req.signal }); ``` @@ -123,7 +125,8 @@ const run = session.createRun(invocation, { signal: req.signal }); | Parameter | Required | Description | Type | | --- | --- | --- | --- | | invocation | required | The `Invocation` carrying run identity and conversation context. | | -| runtime | optional | Per-run hooks and an external abort signal. |
| +| identity | optional | Run ids to override. Each absent field is generated. Omit it on the one-request path. |
| +| hooks | optional | Per-run hooks and an external abort signal. |
|
@@ -136,14 +139,21 @@ const run = session.createRun(invocation, { signal: req.signal }); - + + +| Property | Description | Type | +| --- | --- | --- | +| invocationId | Override the invocation id for this run. Defaults to a fresh `crypto.randomUUID()` (the normal path; one per HTTP request). Supply a non-empty value for deterministic ids in tests. The empty string is rejected. | String | +| runId | Override the run id for a fresh run. Defaults to a fresh `crypto.randomUUID()`. Continuations ignore this and read the existing `runId` off the triggering input event. Supply a non-empty value for deterministic ids in tests. The empty string is rejected. Under [durable execution](/docs/ai-transport/features/durable-execution), supply a stable value so a fresh-process retry re-enters the same run instead of opening a parallel one. | String | + + + + | Property | Description | Type | | --- | --- | --- | -| invocationId | Override the invocation id for this run. Defaults to a fresh `crypto.randomUUID()` (the normal path; one per HTTP request). Supply a non-empty value for deterministic ids in tests. | String | -| runId | Override the run id for a fresh run. Defaults to a fresh `crypto.randomUUID()`. Continuations ignore this and read the existing `runId` off the triggering input event. Supply a non-empty value for deterministic ids in tests. | String | | signal | External `AbortSignal` (typically the HTTP request's `req.signal`) that cancels the run when fired. | `AbortSignal` | -| onMessage | Called before each Ably message is published. Mutate the message in place to add custom headers under `extras.ai`. | `(message: Ably.Message) => void` | +| onAblyMessage | Called before each Ably message is published. Mutate the message in place to add custom headers under `extras.ai`. | `(message: Ably.Message) => void` | | onCancelled | Called when the run is cancelled. Receives a `write` function to publish final outputs before cancellation finalises. | `(write: (output: TOutput) => Promise) => void \| Promise` | | onCancel | Called when a cancel arrives. Return `true` to accept, `false` to reject. Defaults to accepting all. | `(req: CancelRequest) => Promise` | | onError | Called with non-fatal run-scoped errors. | `(error: Ably.ErrorInfo) => void` | @@ -153,18 +163,19 @@ const run = session.createRun(invocation, { signal: req.signal }); ### Returns -An [`AgentRun`](#run) handle for publishing lifecycle events, user messages, and streamed output. +An `OpenableRun` handle for publishing lifecycle events, user messages, and streamed output. It extends [`AgentRun`](#run) with the [`start`](#run-start) method the caller must call before any other. ## Adopt an existing run -{`adoptRun(identity: AdoptIdentity, runtime?: RunRuntime): AdoptedRun`} +{`adoptRun(invocation: Invocation, identity: RunIdentity, hooks?: RunHooks): AdoptedRun`} Adopt an already-open run by its identity so a fresh process can publish further steps and lifecycle events for it. Returns synchronously and does no I/O; publishes nothing to the session until [`AdoptedRun.load`](#adopted-load) resolves. Use it from a step, tool, or cleanup activity that [runs in a separate process](/docs/ai-transport/features/durable-execution) from the one that opened the run. ```javascript const run = session.adoptRun( - { runId, invocationId, triggerEventId }, + Invocation.fromJSON(invocationData), + { runId, invocationId }, { signal: Context.current().cancellationSignal }, ); await run.load(); @@ -177,18 +188,18 @@ await run.load(); | Parameter | Required | Description | Type | | --- | --- | --- | --- | -| identity | required | The run's identity, threaded across the process boundary by the workflow that opened it. |
| -| runtime | optional | Per-run hooks and an external abort signal. `runId` and `invocationId` overrides do not apply here; identity comes from the `identity` argument. |
| +| invocation | required | The `Invocation` pointing at the event whose headers resolve the run's write-time anchors, typically an `ai-input` for a normal turn. Every activity of an invocation resolves against the same trigger. |
| +| identity | required | The run's identity, threaded across the process boundary by the workflow that opened it. |
| +| hooks | optional | Per-run hooks and an external abort signal. |
|
- + | Property | Description | Type | | --- | --- | --- | | runId | The existing run's id. Authoritative: unlike `createRun`'s continuation path, `AdoptedRun.load` does not re-key the run from the trigger event's `run-id` header. | String | | invocationId | This activity's invocation id (a step activity's id, or a cancel-cleanup id). Stamped on every event this process publishes for the run. Independent of the run's owner identity. | String | -| triggerEventId | The id of the event whose headers resolve the run's write-time anchors, typically an `ai-input` for a normal turn. Every activity of an invocation resolves against the same trigger. | String | @@ -267,9 +278,11 @@ await run.end({ reason: 'complete' }); ### Pipe the response stream -{`pipe(stream: ReadableStream, options?: PipeOptions): Promise`} +{`pipe(source: PipeSource): Promise`} -Pipe a `ReadableStream` of outputs through the encoder to the session. Returns when the stream completes, is cancelled, or errors. Does NOT call `end()`; the caller must call `end()` after `pipe()` returns. +Pipe a source of outputs through the encoder to the session. Returns when the source completes, is cancelled, or errors. Does NOT call `end()`; the caller must call `end()` after `pipe()` returns. + +The source is a `ReadableStream` or any `AsyncIterable` of codec outputs, so a provider SDK stream that is async-iterable pipes in directly with no `ReadableStream` wrapper. #### Parameters @@ -277,18 +290,7 @@ Pipe a `ReadableStream` of outputs through the encoder to the session. Returns w | Parameter | Required | Description | Type | | --- | --- | --- | --- | -| stream | required | The output stream from your LLM call. | `ReadableStream` | -| options | optional | Branching and per-output hooks. | | - -
- - - -| Property | Description | Type | -| --- | --- | --- | -| parent | The codec-message-id of the immediately preceding message in this branch. | String | -| forkOf | The codec-message-id of the message this response replaces (for regeneration). | String | -| resolveWriteOptions | Per-output hook returning `WriteOptions` overrides for a single encode call. | Function | +| source | required | The output source from your LLM call. | `ReadableStream \| AsyncIterable` | @@ -400,9 +402,9 @@ Publish `ai-step-start`, opening the step for output. Call once, after the run i ### Pipe outputs
-{`pipe(stream: ReadableStream, options?: PipeOptions): Promise`} +{`pipe(source: PipeSource): Promise`} -Pipe an output stream through the encoder to the session, stamping every output with this step's `step-id` and its attempt's `start-serial`. Otherwise identical to [`AgentRun.pipe`](#pipe): resolves when the stream completes, is cancelled, or errors. A stream error returns `{ reason: 'error' }` rather than throwing, and marks the step `'failed'` when [`end`](#step-end) closes it. +Pipe an output source through the encoder to the session, stamping every output with this step's `step-id` and its attempt's `step-start-serial`. Otherwise identical to [`AgentRun.pipe`](#pipe): resolves when the source completes, is cancelled, or errors. A stream error returns `{ reason: 'error' }` rather than throwing, and marks the step `'failed'` when [`end`](#step-end) closes it. #### Parameters @@ -410,8 +412,7 @@ Pipe an output stream through the encoder to the session, stamping every output | Parameter | Required | Description | Type | | --- | --- | --- | --- | -| stream | required | The output stream from your LLM call. | `ReadableStream` | -| options | optional | Per-stream overrides. A per-output `resolveWriteOptions` merges over the step's default headers, so a normal override leaves `step-id` intact. | | +| source | required | The output source from your LLM call. | `ReadableStream \| AsyncIterable` |
@@ -423,7 +424,7 @@ Pipe an output stream through the encoder to the session, stamping every output {`send(output: TOutput): Promise`} -Publish a single discrete output as one assistant message on the session, stamped with this step's `step-id` and its attempt's `start-serial`. Use it when the output is already resolved (a tool result, a data payload, a metadata event) rather than a streamed source. Each `send` mints its own `codec-message-id`, so N calls produce N assistant messages rather than one. For streamed output from a long-running source, use [`pipe`](#step-pipe) instead. +Publish a single discrete output as one assistant message on the session, stamped with this step's `step-id` and its attempt's `step-start-serial`. Use it when the output is already resolved (a tool result, a data payload, a metadata event) rather than a streamed source. Each `send` generates its own `codec-message-id`, so N calls produce N assistant messages rather than one. For streamed output from a long-running source, use [`pipe`](#step-pipe) instead. The step must be active (started and not yet ended). Rejects otherwise. A publish failure throws. @@ -624,7 +625,7 @@ export async function POST(req: Request) { await session.connect(); - const run = session.createRun(invocation, { signal: req.signal }); + const run = session.createRun(invocation, {}, { signal: req.signal }); try { // Rebuild the conversation from run.view before run.start(): draining pages diff --git a/src/pages/docs/ai-transport/api/javascript/core/codec.mdx b/src/pages/docs/ai-transport/api/javascript/core/codec.mdx index a045ee8c3a..b1749a7ea6 100644 --- a/src/pages/docs/ai-transport/api/javascript/core/codec.mdx +++ b/src/pages/docs/ai-transport/api/javascript/core/codec.mdx @@ -9,7 +9,7 @@ redirect_from: The `Codec` interface is the bridge between an AI framework and Ably channel messages. It describes the wire as a flat stream of input and output events and folds those events into a per-run projection that the SDK extracts messages from for the conversation tree. -Implement `Codec` to integrate any AI framework with AI Transport. The SDK ships the Vercel codec via [`createUIMessageCodec()`](/docs/ai-transport/api/javascript/vercel/codec) for the Vercel AI SDK. For other frameworks, implement the methods below. +Implement `Codec` to integrate any AI framework with AI Transport. The SDK ships two codecs: [`createUIMessageCodec()`](/docs/ai-transport/api/javascript/vercel/codec) for the Vercel AI SDK, and [`ResponsesCodec`](/docs/ai-transport/api/javascript/openai/codec) from `@ably/ai-transport/openai` for the OpenAI Responses API. For other frameworks, implement the methods below. ```javascript @@ -92,7 +92,7 @@ Build a stateful encoder bound to a channel. The encoder owns the message-append | Parameter | Required | Description | Type | | --- | --- | --- | --- | | channel | required | The channel writer to publish through. An `Ably.RealtimeChannel` satisfies this directly. | `ChannelWriter` | -| options | optional | Per-encoder defaults (extras, messageId, onMessage hook). | | +| options | optional | Per-encoder defaults (extras, messageId, onAblyMessage hook). |
|
@@ -101,7 +101,7 @@ Build a stateful encoder bound to a channel. The encoder owns the message-append | Property | Description | Type | | --- | --- | --- | | extras | Default extras merged into every Ably message. | | -| onMessage | Hook called before each Ably message is published. Mutate the message in place to add transport-level headers under `extras.ai`. | `(message: Ably.Message) => void` | +| onAblyMessage | Hook called before each Ably message is published. Mutate the message in place to add transport-level headers under `extras.ai`. | `(message: Ably.Message) => void` | | messageId | Default `codec-message-id` for messages where the event payload doesn't supply one. | String |
diff --git a/src/pages/docs/ai-transport/api/javascript/openai/codec.mdx b/src/pages/docs/ai-transport/api/javascript/openai/codec.mdx new file mode 100644 index 0000000000..81a280f319 --- /dev/null +++ b/src/pages/docs/ai-transport/api/javascript/openai/codec.mdx @@ -0,0 +1,200 @@ +--- +title: "ResponsesCodec" +meta_description: "API reference for ResponsesCodec, the pre-built AI Transport codec for the OpenAI Responses API." +meta_keywords: "OpenAI, Responses API, AI Transport, ResponsesCodec, OpenAIInput, OpenAIOutput, OpenAIProjection, OpenAIMessage, call_id, codec, Ably" +--- + +`ResponsesCodec` is the pre-built codec for the [OpenAI Responses API](/docs/ai-transport/frameworks/openai). It implements `Codec`, so a session encodes a `ResponseStreamEvent` stream out and decodes it back into `OpenAIMessage` objects without a custom implementation. + +It is a single codec value, so you pass `ResponsesCodec` itself and never call it. It takes no type parameters, and one instance serves every session in the process. + +On the agent, bind it to the session: + + +```javascript +import { createAgentSession } from '@ably/ai-transport'; +import { ResponsesCodec } from '@ably/ai-transport/openai'; + +const session = createAgentSession({ + client: ably, + channelName: invocation.sessionName, + codec: ResponsesCodec, +}); +``` + + +On the client, the same value goes to the React provider: + + +```javascript +'use client'; + +import { ClientSessionProvider } from '@ably/ai-transport/react'; +import { ResponsesCodec } from '@ably/ai-transport/openai'; + + + + +``` + + + + +You rarely call the methods below yourself. A session drives `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
+ + + +| Property | Description | Type | +| --- | --- | --- | +| init | Build an empty `OpenAIProjection`. | `() => OpenAIProjection` | +| fold | Fold an `OpenAIInput` or `OpenAIOutput` into the projection. | `(state, event, meta) => OpenAIProjection` | +| createEncoder | Create an OpenAI encoder bound to the supplied channel writer. | `(channel, options?) => Encoder` | +| createDecoder | Create an OpenAI decoder for the channel. | `() => Decoder` | +| getMessages | Extract `{ codecMessageId, message }` pairs from an `OpenAIProjection`. | `(projection) => CodecMessage[]` | +| createUserMessage | Wrap an `OpenAIMessage` as the `UserMessage` input variant. | `(message: OpenAIMessage) => OpenAIInput` | +| createRegenerate | Build a `Regenerate` input targeting an assistant message. | `(target, parent) => OpenAIInput` | +| createToolResult | Build a `ToolResult` input addressed at the assistant message that holds the `function_call`. Payload keyed by [`call_id`](#tool-payloads). | `(codecMessageId, payload) => OpenAIInput` | +| createToolResultError | Build a `ToolResultError` input for a tool that failed. Payload keyed by [`call_id`](#tool-payloads). | `(codecMessageId, payload) => OpenAIInput` | +| createToolApprovalResponse | Build a `ToolApprovalResponse` input answering a gated call. Payload keyed by [`call_id`](#tool-payloads). | `(codecMessageId, payload) => OpenAIInput` | + +
+ +## 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. + + + +| Property | Description | Type | +| --- | --- | --- | +| call_id | The `call_id` of the `function_call` this result answers. | String | +| output | The tool's output, either text or a content list. Exactly the `function_call_output.output` shape, so it reaches the model unchanged. | `Responses.ResponseInputItem.FunctionCallOutput['output']` | + +
+ + + +| Property | Description | Type | +| --- | --- | --- | +| call_id | The `call_id` of the `function_call` that failed. | String | +| message | Human-readable description of the failure. The reducer folds it into the `function_call_output.output`, so it becomes the output the model reads on the next turn. | String | + +
+ + + +| Property | Description | Type | +| --- | --- | --- | +| call_id | The `call_id` of the gated `function_call`. | String | +| approved | Whether the user approved the tool execution. | Boolean | +| reason | Optional human-readable reason, typically supplied on a denial. | String | + +
+ +An approval records a decision and produces no output, so the agent runs an approved call server-side when it resumes. A denial needs no server execution, because the reducer folds a rejection `function_call_output` on the client. [Conversation helpers](/docs/ai-transport/api/javascript/openai/conversation-helpers#correlation) covers reading that state back. + +## Types
+ + + +| Property | Description | Type | +| --- | --- | --- | +| role | Whether the message is the user's prompt or the assistant's reply. | `'user' \| 'assistant'` | +| items | The message's items, in wire order. An assistant message can hold several, for example reasoning followed by a `function_call`. | `OpenAIItem[]` | +| toolCallStates | Approval and client-execution state, keyed by `call_id`. Present only when the message holds at least one tool call, because OpenAI's item model has no field for either. | `Record` | + +
+ + + +| Property | Description | Type | +| --- | --- | --- | +| OpenAIItem | One item inside a message. Every member is a valid Responses API input item, which is why a stored conversation round-trips to `/responses` with no conversion. | `ResponseOutputMessage \| ResponseReasoningItem \| ResponseFunctionToolCall \| ResponseInputItem.FunctionCallOutput \| ResponseInputItem.Message` | + +
+ + + +| Property | Description | Type | +| --- | --- | --- | +| approval | A gated call's approval status, set when the agent requests approval and updated by the client's response. | `'pending' \| 'approved' \| 'denied'` | +| result | The client-side execution status, set once a tool result or a tool error folds in. | `'ok' \| 'failed'` | +| name | The tool name, carried on the approval request. | String | +| arguments | The tool arguments as JSON text, carried on the approval request. | String | +| reason | Optional reason accompanying an approval decision. | String | + +
+ + + +| Property | Description | Type | +| --- | --- | --- | +| type | Discriminator. | `'tool-approval-request'` | +| call_id | The `call_id` of the `function_call` this approval gates. | String | +| name | The tool's name, so a client renders the prompt without waiting for the streamed `function_call`. | String | +| arguments | The tool's arguments as JSON text, mirroring the `function_call`. | String | + +
+ + + +| Property | Description | Type | +| --- | --- | --- | +| OpenAIInput | Every record-shape a client publishes on the `ai-input` wire. The SDK's well-known input variants, with the tool variants parameterised by the OpenAI payload shapes. | `UserMessage \| Regenerate \| ToolResult \| ToolResultError \| ToolApprovalResponse` | + +
+ + + +| Property | Description | Type | +| --- | --- | --- | +| OpenAIOutput | Every record-shape the agent publishes on the `ai-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. | `ResponseStreamEvent \| { type: 'function_call_output', item } \| ToolApprovalRequestEvent` | + +
+ +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](/docs/ai-transport/frameworks/openai#scope) describes. + + + +| Property | Description | Type | +| --- | --- | --- | +| OpenAIProjection | Per-run projection, carrying the `{ codecMessageId, message }` pair list in publication order. The SDK does not inspect this shape. Use `getMessages` instead. | `{ messages: CodecMessage[] }` | + +
+ +The well-known input variants ([`UserMessage`](/docs/ai-transport/api/javascript/core/codec#user-message), [`Regenerate`](/docs/ai-transport/api/javascript/core/codec#regenerate), [`ToolResult`](/docs/ai-transport/api/javascript/core/codec#tool-result), [`ToolResultError`](/docs/ai-transport/api/javascript/core/codec#tool-result-error), [`ToolApprovalResponse`](/docs/ai-transport/api/javascript/core/codec#tool-approval-response)) are documented on the [`Codec` reference page](/docs/ai-transport/api/javascript/core/codec#input-variants). + +## Example
+ +Decode a single Ably message and fold the resulting events into a fresh projection. `ReducerMeta.serial` is required, because the reducer uses it as the high-water-mark for idempotency. + + +```javascript +import { ResponsesCodec } from '@ably/ai-transport/openai'; + +const decoder = ResponsesCodec.createDecoder(); +let projection = ResponsesCodec.init(); + +channel.subscribe((message) => { + if (!message.serial) return; // live channel-subscribe messages always carry one + const { inputs, outputs } = decoder.decode(message); + for (const input of inputs) { + projection = ResponsesCodec.fold(projection, input, { serial: message.serial }); + } + for (const output of outputs) { + projection = ResponsesCodec.fold(projection, output, { serial: message.serial }); + } + render(ResponsesCodec.getMessages(projection).map((entry) => entry.message)); +}); +``` + + +## Read next + +{`toResponsesInput(messages: OpenAIMessage[]): Responses.ResponseInputItem[]`} + +Flatten a conversation into the `input` array for a Responses API call. Each stored message already holds valid Responses input items, so the flatten needs no conversion and loses nothing. + +Every item the codec stores is a `ResponseInputItem`, so adding an item type the model cannot accept breaks the build at this function instead of failing at request time. + +`toResponsesInput` reads only `message.items`. It never reads `toolCallStates`, so approval and client-execution state cannot reach the model. + +### Parameters + + + +| Parameter | Required | Description | Type | +| --- | --- | --- | --- | +| messages | required | The conversation to flatten, usually `run.view.getMessages().map(({ message }) => message)` after draining history. | `OpenAIMessage[]` | + +
+ +### Returns
+ +`Responses.ResponseInputItem[]`. Pass it straight to `openai.responses.create({ input, ... })`. + +## Correlation readers + +The three readers all take the same `OpenAIMessage[]` and answer a different question about the run's tool calls. An agent resuming a run reads `unansweredCalls` to decide whether it can continue at all, and `approvedUnexecutedCalls` to find the work it owes before the next model turn. + +### resolvedCallIds + +{`resolvedCallIds(messages: OpenAIMessage[]): Set`} + +The `call_id` of every `function_call_output` in the conversation. A resolved call has its output folded in, so a renderer shows that output attached to the call, and a loop skips it. + +### unansweredCalls + +{`unansweredCalls(messages: OpenAIMessage[]): Responses.ResponseFunctionToolCall[]`} + +The function calls that still owe the model an answer, in message and item order. + +This is the guard on resuming a run. The model input must carry a matching output for every open `function_call`, so resuming while any call is unanswered makes the provider reject the request. A turn that emits two gated calls therefore needs both answers before either one wakes the agent. + + + +| Call state | Counts as | Why | +| --- | --- | --- | +| Has a `function_call_output` | Answered | The output has folded in, which is what `resolvedCallIds` reports. | +| Approved, no output yet | Answered | The agent runs it server-side on resume, so the output exists by the time the model sees the conversation. `approvedUnexecutedCalls` returns these. | +| Denied | Answered | The reducer resolves a denial with a rejection `function_call_output`. | +| Pending a decision | Unanswered | Nobody has approved or denied it yet. | +| Client-executed, result not arrived | Unanswered | The browser has not published its result. | + +
+ +A client's own resolution is wire-only, because the transport skips the optimistic fold for an input targeting an existing message. A client deciding whether it answered the run's last open call should track the `call_id`s it has answered rather than waiting for them to appear in its view. + +### approvedUnexecutedCalls
+ +{`approvedUnexecutedCalls(messages: OpenAIMessage[]): Responses.ResponseFunctionToolCall[]`} + +The gated calls the user approved that the agent has not run yet, in message and item order. Each is a `function_call` whose `toolCallStates[call_id].approval` is `'approved'` with no `function_call_output` present. + +An approval is a decision and carries no output, so on resume the agent runs each of these server-side, publishes the outputs, and feeds them back before the next model turn. Skipping this step leaves a dangling `function_call` that the provider rejects. + +## Read next {`run(runId: string): RunInfo | undefined`} diff --git a/src/pages/docs/ai-transport/features/agent-presence.mdx b/src/pages/docs/ai-transport/features/agent-presence.mdx index 22d9374ebf..b7e6d2d5c8 100644 --- a/src/pages/docs/ai-transport/features/agent-presence.mdx +++ b/src/pages/docs/ai-transport/features/agent-presence.mdx @@ -27,7 +27,7 @@ app.post('/api/chat', async (req, res) => { const invocation = Invocation.fromJSON(await req.json()); const session = createAgentSession({ client: ably, channelName: invocation.sessionName, codec: createUIMessageCodec() }); await session.connect(); - const run = session.createRun(invocation, { signal: req.signal }); + const run = session.createRun(invocation, {}, { signal: req.signal }); // Enter presence so every connected client sees what the agent is doing. await session.presence.enter({ status: 'thinking' }); diff --git a/src/pages/docs/ai-transport/features/branching.mdx b/src/pages/docs/ai-transport/features/branching.mdx index 42026e15cf..387a061943 100644 --- a/src/pages/docs/ai-transport/features/branching.mdx +++ b/src/pages/docs/ai-transport/features/branching.mdx @@ -119,7 +119,7 @@ The agent doesn't usually need bespoke branching logic. The invocation arrives, ```javascript const invocation = Invocation.fromJSON(await req.json()); -const run = session.createRun(invocation, { signal: req.signal }); +const run = session.createRun(invocation, {}, { signal: req.signal }); try { // Rebuild the conversation from run.view before run.start(): draining pages in diff --git a/src/pages/docs/ai-transport/features/cancellation.mdx b/src/pages/docs/ai-transport/features/cancellation.mdx index 0aacc1a674..56942c7004 100644 --- a/src/pages/docs/ai-transport/features/cancellation.mdx +++ b/src/pages/docs/ai-transport/features/cancellation.mdx @@ -63,7 +63,7 @@ Every run exposes an `abortSignal` that fires when the run is cancelled. Pass it ```javascript -const run = session.createRun(invocation, { signal: req.signal }); +const run = session.createRun(invocation, {}, { signal: req.signal }); // Drain run.view for the full conversation to feed the model. run.messages // is only this run's own turn. @@ -86,13 +86,13 @@ await run.end({ reason }); ### Authorise the cancel -The `onCancel` hook on `RunRuntime` authorises or rejects cancel requests: +The `onCancel` hook on `RunHooks` authorises or rejects cancel requests: ```javascript const userId = await authenticateUser(req); -const run = session.createRun(invocation, { +const run = session.createRun(invocation, {}, { signal: req.signal, onCancel: async (request) => request.message.clientId === userId, }); @@ -107,7 +107,7 @@ The `onCancelled` hook runs when the abort signal fires, giving you a chance to ```javascript -const run = session.createRun(invocation, { +const run = session.createRun(invocation, {}, { signal: req.signal, onCancelled: async (write) => { await write({ type: 'text-delta', id: 'cancel-note', delta: '\n[Response cancelled]' }); diff --git a/src/pages/docs/ai-transport/features/chain-of-thought.mdx b/src/pages/docs/ai-transport/features/chain-of-thought.mdx index a9c0b17754..3b671d8a2f 100644 --- a/src/pages/docs/ai-transport/features/chain-of-thought.mdx +++ b/src/pages/docs/ai-transport/features/chain-of-thought.mdx @@ -23,7 +23,7 @@ app.post('/api/chat', async (req, res) => { const invocation = Invocation.fromJSON(await req.json()); const session = createAgentSession({ client: ably, channelName: invocation.sessionName, codec: createUIMessageCodec() }); await session.connect(); - const run = session.createRun(invocation, { signal: req.signal }); + const run = session.createRun(invocation, {}, { signal: req.signal }); // Rebuild the conversation from run.view before run.start(): draining pages in // this run's triggering input (otherwise run.start() awaits it arriving live). diff --git a/src/pages/docs/ai-transport/features/concurrent-turns.mdx b/src/pages/docs/ai-transport/features/concurrent-turns.mdx index 2212a8c23c..028ca90879 100644 --- a/src/pages/docs/ai-transport/features/concurrent-turns.mdx +++ b/src/pages/docs/ai-transport/features/concurrent-turns.mdx @@ -44,7 +44,7 @@ app.post('/api/chat', async (req, res) => { const invocation = Invocation.fromJSON(await req.json()); const session = createAgentSession({ client: ably, channelName: invocation.sessionName, codec: createUIMessageCodec() }); await session.connect(); - const run = session.createRun(invocation, { signal: req.signal }); + const run = session.createRun(invocation, {}, { signal: req.signal }); // Rebuild the conversation from run.view before run.start(): draining pages in // this run's triggering input (otherwise run.start() awaits it arriving live). @@ -198,7 +198,7 @@ app.post('/api/chat', async (req, res) => { // The orchestrator creates additional invocations for sub-agents and POSTs // to their endpoints. Each sub-agent runs its own session.createRun(...) // against the same channel; their messages multiplex by runId. - const researchRun = session.createRun(invocation, { signal: req.signal }); + const researchRun = session.createRun(invocation, {}, { signal: req.signal }); await researchRun.start(); await runResearchAgent(researchRun); diff --git a/src/pages/docs/ai-transport/features/database-hydration.mdx b/src/pages/docs/ai-transport/features/database-hydration.mdx index 165b4527a1..a13969b68d 100644 --- a/src/pages/docs/ai-transport/features/database-hydration.mdx +++ b/src/pages/docs/ai-transport/features/database-hydration.mdx @@ -33,7 +33,7 @@ The agent rebuilds the model context from your store and the live session. Seed ```javascript const session = createAgentSession({ client: ably, channelName: invocation.sessionName }); await session.connect(); -const run = session.createRun(invocation, { signal: req.signal }); +const run = session.createRun(invocation, {}, { signal: req.signal }); // Seed the prior conversation from your store; the newest stored message is the seam. const seed = loadMessages(invocation.sessionName); diff --git a/src/pages/docs/ai-transport/features/durable-execution.mdx b/src/pages/docs/ai-transport/features/durable-execution.mdx index 22a840ccb2..843e5b5b24 100644 --- a/src/pages/docs/ai-transport/features/durable-execution.mdx +++ b/src/pages/docs/ai-transport/features/durable-execution.mdx @@ -15,15 +15,15 @@ AI Transport works alongside any durable execution engine that gives each retrya Each retryable step or activity in the workflow engine maps to an SDK [step](/docs/ai-transport/concepts/runs#steps) with a `stepId`. When the workflow engine retries a failed activity under the same `stepId`, the SDK supersedes the previous attempt's partial output and the conversation history stays clean. -On the agent, to create a step inside a run, the SDK needs three identifiers: `runId`, `invocationId`, and `triggerEventId`. These identifiers allow different retryable activities, scheduled across different durable-execution workers, to contribute steps to an existing run, even if that run was started in a different activity: +On the agent, to create a step inside a run, the SDK needs the run's two identifiers, `runId` and `invocationId`, plus the invocation that triggered the turn. These identifiers allow different retryable activities, scheduled across different durable-execution workers, to contribute steps to an existing run, even if that run was started in a different activity: ```javascript import * as Ably from 'ably'; -import { createAgentSession } from '@ably/ai-transport'; +import { createAgentSession, Invocation } from '@ably/ai-transport'; import { createUIMessageCodec } from '@ably/ai-transport/vercel'; -async function runToolStep({ runId, invocationId, triggerEventId, activityId, toolCall }) { +async function runToolStep({ invocation, runId, invocationId, activityId, toolCall }) { const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY }); const session = createAgentSession({ client: ably, @@ -32,7 +32,7 @@ async function runToolStep({ runId, invocationId, triggerEventId, activityId, to }); await session.connect(); - const run = session.adoptRun({ runId, invocationId, triggerEventId }); + const run = session.adoptRun(Invocation.fromJSON(invocation), { runId, invocationId }); await run.load(); const output = await runYourTool(toolCall); diff --git a/src/pages/docs/ai-transport/features/interruption-and-steering.mdx b/src/pages/docs/ai-transport/features/interruption-and-steering.mdx index fd70ef8afd..cdc64b7108 100644 --- a/src/pages/docs/ai-transport/features/interruption-and-steering.mdx +++ b/src/pages/docs/ai-transport/features/interruption-and-steering.mdx @@ -70,7 +70,7 @@ On the agent side, `Run.hasInput()` controls the loop. It returns `true` while t ```javascript -const run = session.createRun(invocation, { signal: req.signal }); +const run = session.createRun(invocation, {}, { signal: req.signal }); // Drain run.view for the full conversation before starting. run.messages is // only this run's own turn, so feed run.view to the model instead. @@ -104,7 +104,7 @@ On the agent, give each model call its own `AbortController`, abort it from `onS ```javascript let stepAbort = new AbortController(); -const run = session.createRun(invocation, { +const run = session.createRun(invocation, {}, { signal: req.signal, // Abort only the current model call, never run.abortSignal, so a steering // message stops the response without cancelling the run. diff --git a/src/pages/docs/ai-transport/features/push-notifications.mdx b/src/pages/docs/ai-transport/features/push-notifications.mdx index 0d45195b5c..4882183102 100644 --- a/src/pages/docs/ai-transport/features/push-notifications.mdx +++ b/src/pages/docs/ai-transport/features/push-notifications.mdx @@ -40,7 +40,7 @@ app.post('/api/chat', async (req, res) => { const invocation = Invocation.fromJSON(body) const session = createAgentSession({ client: ably, channelName: invocation.sessionName, codec: createUIMessageCodec() }) await session.connect() - const run = session.createRun(invocation, { signal: req.signal }) + const run = session.createRun(invocation, {}, { signal: req.signal }) // Rebuild the conversation from run.view before run.start(): draining pages in // this run's triggering input (otherwise run.start() awaits it arriving live). diff --git a/src/pages/docs/ai-transport/features/token-streaming.mdx b/src/pages/docs/ai-transport/features/token-streaming.mdx index c844cd7d6e..3300ebdecd 100644 --- a/src/pages/docs/ai-transport/features/token-streaming.mdx +++ b/src/pages/docs/ai-transport/features/token-streaming.mdx @@ -81,7 +81,7 @@ import { createAgentSession } from '@ably/ai-transport/vercel'; const invocation = Invocation.fromJSON(await req.json()); const session = createAgentSession({ client: ably, channelName: invocation.sessionName }); await session.connect(); -const run = session.createRun(invocation, { signal: req.signal }); +const run = session.createRun(invocation, {}, { signal: req.signal }); // Rebuild the conversation from run.view before run.start(): draining pages in // this run's triggering input (otherwise run.start() awaits it arriving live). diff --git a/src/pages/docs/ai-transport/features/tool-calling.mdx b/src/pages/docs/ai-transport/features/tool-calling.mdx index 4faa7b86f6..9f8d703b86 100644 --- a/src/pages/docs/ai-transport/features/tool-calling.mdx +++ b/src/pages/docs/ai-transport/features/tool-calling.mdx @@ -126,6 +126,29 @@ if (pending) { The result is addressed to the suspended assistant message by `codecMessageId`. Reusing the original `runId` keeps the resume on the same run instead of starting a fresh one. +## OpenAI codec + +The examples above use the Vercel codec, but the OpenAI Responses codec supports the same tool surface: server-executed function calls, client-executed tools, tool failures, and human approvals. The suspend and resume mechanics live in the transport, so they are identical for both codecs. The wire types and the factory payload field names differ. + +The OpenAI codec expresses tool state against the Responses types, so a tool call is a `function_call` item and its result is a `function_call_output` item. The client factories take snake_case payloads keyed by `call_id`, and you address each to the assistant message that holds the call: + + +```javascript +import { ResponsesCodec } from '@ably/ai-transport/openai'; + +// A client-run tool succeeded. +await view.send(ResponsesCodec.createToolResult(codecMessageId, { call_id, output }), { runId }); + +// A client-run tool failed. The message becomes the output the model sees next turn. +await view.send(ResponsesCodec.createToolResultError(codecMessageId, { call_id, message }), { runId }); + +// A user approved or denied a gated tool. A denial resolves entirely on the client. +await view.send(ResponsesCodec.createToolApprovalResponse(codecMessageId, { call_id, approved, reason }), { runId }); +``` + + +The Responses `function_call_output` item has no field for an approval decision or an error, so the codec holds that render-only state on `OpenAIMessage.toolCallStates`, a map keyed by `call_id`. `toResponsesInput` never reads it, so it cannot reach the model. See [OpenAI Responses](/docs/ai-transport/frameworks/openai) for the agentic loop and the approval-request output. + ## History persistence Tool invocations and results are part of the session's history. When a client reconnects or a late joiner loads the conversation, tool activity is replayed along with text messages. The view reconstructs tool state so the UI shows the correct status: pending, complete, or failed. diff --git a/src/pages/docs/ai-transport/frameworks/openai.mdx b/src/pages/docs/ai-transport/frameworks/openai.mdx new file mode 100644 index 0000000000..56c3f62949 --- /dev/null +++ b/src/pages/docs/ai-transport/frameworks/openai.mdx @@ -0,0 +1,205 @@ +--- +title: "OpenAI Responses" +meta_description: "How Ably AI Transport integrates with the OpenAI Responses API. The ResponsesCodec encodes the Responses event stream onto an Ably channel, and toResponsesInput feeds the conversation back to the model." +meta_keywords: "AI Transport, OpenAI, Responses API, ResponsesCodec, toResponsesInput, function calling, client-side tools, tool approval, reasoning, durable sessions, server" +intro: "The OpenAI Responses API streams model output as typed events over HTTP. AI Transport's ResponsesCodec encodes that stream onto an Ably channel, so the same server code feeds durable sessions instead of an ephemeral HTTP response." +--- + +The [OpenAI Responses API](https://platform.openai.com/docs/api-reference/responses) is OpenAI's interface for calling a model and getting back a stream of typed events: output text, reasoning, refusals, function-call arguments, and lifecycle events. AI Transport writes that stream to an Ably channel, so reconnects, multi-device, and bidirectional control come for free. + +The `ResponsesCodec` from `@ably/ai-transport/openai` passes each raw Responses event through to the channel and reassembles the conversation on the client. There is no OpenAI-specific transport, so you use the generic [`createAgentSession`](/docs/ai-transport/api/javascript/core/agent-session) from `@ably/ai-transport` and pass it the codec. + +To build a working app with this codec, see [Get started with OpenAI](/docs/ai-transport/getting-started/openai). + +## What the OpenAI Responses API brings + +The `openai` npm package owns the model call and the typed event model: + +| Capability | Description | +| --- | --- | +| Responses streaming | `client.responses.create({ stream: true })` returns a stream of `ResponseStreamEvent`s: output-text deltas, reasoning, refusals, function-call arguments, and item lifecycle events. | +| Server-side tools | You advertise function tools on the request. The model emits function calls, you run them, and you feed the outputs back on the next Responses API call. | +| Reasoning models | Reasoning items carry the model's summarised thinking, and `encrypted_content` for the no-store, zero-data-retention round trip. | +| Round-trippable items | Each output item the codec stores is also valid Responses API input, so the conversation feeds the next turn without conversion. | +| Typed SDK | The official SDK owns auth, the HTTP call, streaming, and the Responses type definitions. | + +## What AI Transport adds + +AI Transport adds to the Responses API, writing the model stream to a durable session that outlives any single connection: + +| Capability | Description | +| --- | --- | +| Durable sessions | Tokens flow through an Ably channel that outlives any single connection. A client reconnects and resumes from where it left off. | +| Multi-device sync | Every device subscribed to the session sees the same conversation in realtime. | +| Bidirectional control | Cancel, steer, and interrupt the agent from any client. No separate control channel. | +| Active run tracking | `view.runs()` exposes which clients have Runs streaming and which Runs are in progress. | +| Conversation branching | Edit and regenerate create forks in the conversation tree, not destructive replacements. | +| Approval gates that reach the user anywhere | Pending tool approvals persist on the session until someone acts on them. | +| History and replay | Load the full conversation on reconnect, page refresh, or new device join. | +| Token compaction | Reconnecting clients receive accumulated responses, not a replay of every token. | + +## Where they connect + +On the server, AI Transport swaps the HTTP response sink for `Run.pipe()`. Without it, the OpenAI SDK gives you no helper for forwarding a Responses stream over HTTP, so you serialise each event into a Server-Sent Events response by hand: + + +```javascript +const encoder = new TextEncoder(); +return new Response( + new ReadableStream({ + async start(controller) { + for await (const event of stream) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } + controller.close(); + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } }, +); +``` + + +With AI Transport, a raw `ResponseStreamEvent` is already valid codec output and `run.pipe` takes the SDK's async iterable directly, so the same stream reaches a durable session in two lines, and every subscribed client resumes, syncs, and cancels it: + + +```javascript +const { reason } = await run.pipe(stream); +await run.end({ reason }); +``` + + +The integration has three pieces: + +1. The `ResponsesCodec` from `@ably/ai-transport/openai` encodes each `ResponseStreamEvent` as Ably messages and reassembles them into `OpenAIMessage`s on the client. It streams assistant text, refusals, reasoning, and function-call arguments, and repairs a client that joins a stream mid-way. The codec passes the raw events through, so the wire tracks OpenAI's own event model. +2. `createAgentSession({ client, channelName, codec: ResponsesCodec })` from `@ably/ai-transport` constructs the agent session bound to the channel from the invocation. +3. `run.pipe()` reads the model's event stream, encodes each event, and publishes the resulting Ably messages. `run.abortSignal` wires cancellation through from the client to the Responses API request. + +To feed the next turn, `toResponsesInput` flattens the conversation read from `run.view` back into the Responses `input` array. Each stored `OpenAIMessage` already holds valid Responses input items, so the flatten needs no conversion: + + +```javascript +import { toResponsesInput } from '@ably/ai-transport/openai'; + +while (run.view.hasOlder()) { + await run.view.loadOlder(); +} +const input = toResponsesInput(run.view.getMessages().map(({ message }) => message)); +``` + + +## Run server-side tools + +A Responses stream never carries a function call's *output*. OpenAI surfaces tool output only as model input on the next turn, so the codec adds an event of its own, `function_call_output`, for the agent to publish after it runs a tool. + +Server-executed tools do not suspend the Run. The agent runs an agentic loop: call the Responses API, and if the model emits function calls, run them, append the model's output items and the tool outputs to the input, and call it again. The loop continues until the model produces a reply with no tool calls. Each unit of work publishes under its own `run.pipe`, so a Run that calls one tool produces three messages: the turn that emitted the calls, the tool outputs, and the final text turn. + +For reasoning models, the loop must re-append the whole turn's output items, including the reasoning items that preceded a function call, since reasoning models expect that reasoning to travel with the call on the next request. The [runnable demo](https://github.com/ably/ably-ai-transport-js/tree/main/demo/openai/react/use-client-session) implements the full loop. + +## Run client-side tools and approvals + +The codec also carries the client-driven half of tool calling: a client executes a tool in the browser and publishes the result, reports a tool failure, or answers a human approval prompt. Gating a call on a human decision needs the codec's second added event, `tool-approval-request`, which the Responses API has no equivalent for. The suspend and resume mechanics belong to the transport rather than the codec. The agent calls `run.suspend()` to wait for a client, the client publishes its input on the same `runId`, and a continuation resumes the Run. + +`ResponsesCodec` exposes the full well-known factory set. You address each client input to the assistant message that holds the `function_call`, and key each payload by the OpenAI snake_case `call_id`: + + +```javascript +import { ResponsesCodec } from '@ably/ai-transport/openai'; + +// A client-run tool succeeded. +await view.send(ResponsesCodec.createToolResult(codecMessageId, { call_id, output }), { runId }); + +// A client-run tool failed. The message becomes the output the model sees next turn. +await view.send(ResponsesCodec.createToolResultError(codecMessageId, { call_id, message }), { runId }); + +// A user approved or denied a gated tool. +await view.send(ResponsesCodec.createToolApprovalResponse(codecMessageId, { call_id, approved, reason }), { runId }); +``` + + +The Responses `function_call_output` item has no field for an approval decision or an error, so the codec holds that render-only state out of band on `OpenAIMessage.toolCallStates`, a map keyed by `call_id`. `toResponsesInput` never reads that state, so it cannot reach the model. + +### Publish an approval request on the call's own message + +To gate a tool on a human decision, the agent publishes the codec's own `tool-approval-request` output, carrying the `call_id`, the tool name, and the arguments so a client can render the prompt without the streamed `function_call`. + +The agent publishes the request as the tail of the model turn's own `run.pipe`, so it lands on the same `codec-message-id` as the `function_call` it gates. The pending approval state, the client's decision, and the `function_call` then fold onto one message. Published as a separate message, the request strands its pending state on a message the client's response never amends, so the approval prompt never resolves and the agent never sees the call as approved. + + +```javascript +// Agent: the model turn, then an approval request per gated call, on one pipe. +async function* modelTurnWithGateRequests(input, signal, turn) { + yield* modelTurnStream(input, signal, turn); + for (const call of turn.calls) { + if (needsApproval(call.name)) { + yield { type: 'tool-approval-request', call_id: call.call_id, name: call.name, arguments: call.arguments }; + } + } +} + +await run.pipe(modelTurnWithGateRequests(input, run.abortSignal, turn)); +``` + + +### Wait for the run to suspend, then wake the agent + +The client waits until the run reports `suspended` before publishing. One model turn can emit a server tool and a client tool on the same message, and resuming while the run is still active races the run's own output, whose server-tool result has not folded in yet. The provider then rejects the resumed request for the missing output. The run flips to `suspended` once the agent pauses it awaiting client input. + +The client answers every open call before waking the agent. The model input must carry a matching output for every open `function_call`, so a turn that emits two gated calls needs both answers before either wakes the agent. The client reads the outstanding calls with `unansweredCalls`. Its own resolution is wire-only, since the transport skips the optimistic fold for an input targeting an existing message, so the client tracks the `call_id`s it has answered instead of waiting for them to appear in the view. + +Publishing an input resumes nothing on its own. As everywhere else in the core SDK, the client publishes and then POSTs the invocation to wake the agent, in that order: the agent reads the conversation off the channel, so the resolution has to be there before the POST lands. + + +```javascript +import { unansweredCalls } from '@ably/ai-transport/openai'; + +const target = view.runOf(codecMessageId); +if (target?.status !== 'suspended') return; + +answered.add(call_id); +const runMessages = view.messages + .filter((entry) => view.runOf(entry.codecMessageId)?.runId === target.runId) + .map((entry) => entry.message); +const answeredTheLastCall = unansweredCalls(runMessages).every((call) => answered.has(call.call_id)); + +const run = await view.send([input], { runId: target.runId }); +if (answeredTheLastCall) await wakeAgent(run); +``` + + +### Run an approved call on resume + +An approval records the user's decision without producing the tool's output, so when the user approves a gated call the conversation holds a `function_call` with no `function_call_output`. The agent runs the tool server-side on resume, before the next model turn. It reads those calls with `approvedUnexecutedCalls`, publishes each output as its own message, and feeds them back into the input: + + +```javascript +import { approvedUnexecutedCalls } from '@ably/ai-transport/openai'; + +const approved = approvedUnexecutedCalls(priorMessages); +if (approved.length > 0) { + const { items, events } = runToolCalls(approved); + await run.pipe(outputStream(events)); + input.push(...items); +} +``` + + +A denial needs no server execution, because the codec resolves it with a rejection `function_call_output` on the client. The run is still suspended, so the client must wake it to continue. + +The third correlation reader, `resolvedCallIds`, returns the `call_id`s that already carry an output, which a renderer uses to skip the calls it has already shown an answer for. + +## Scope and trade-offs + +The `ResponsesCodec` covers the shapes the Responses API streams: assistant text, refusals, reasoning (summary and raw), and server-executed function calls. It also covers the client-driven tool surface: client-executed tools, tool failures, and human approvals. Hosted tools (web and file search, code interpreter, image generation, MCP, custom tools) and audio are not yet supported. + +The codec's event inventory is total, so an event outside it throws at the encoder instead of being dropped silently. An agent that enables a hosted tool must filter that tool's events, and the `output_text` annotations those tools cite, out of the stream before piping it. + +The codec transmits the raw Responses events rather than a normalised abstraction. The wire therefore tracks OpenAI's own event model, which keeps stored items round-trippable to the Responses API but ties a conversation to the Responses shape. To integrate a different model provider behind one abstraction, use [Vercel AI SDK Core](/docs/ai-transport/frameworks/vercel-ai-sdk-core) instead. + +## Read next + +A Next.js chat app where: + +- Output from OpenAI's Responses API streams into a durable [session](/docs/ai-transport/concepts/sessions) that outlives the HTTP response. +- Closing a tab and reopening it resumes the in-progress response. +- A second tab on the same session sees the same conversation in realtime. +- A stop button cancels the in-progress [Run](/docs/ai-transport/concepts/runs). + +The [`ResponsesCodec`](/docs/ai-transport/frameworks/openai) maps the raw Responses event stream onto an Ably channel. You read the conversation with the core [`useView`](/docs/ai-transport/api/react/core/use-view) hook, which gives you branching, edit, regenerate, and pagination directly. + +## Prerequisites + +- Node.js 22 or later. +- An [Ably account](https://ably.com/sign-up) with an API key. +- An OpenAI API key. + +## Install dependencies + +Install the AI Transport SDK, the Ably client, the OpenAI SDK, and Next.js: + + +```shell +npm install @ably/ai-transport ably openai next react react-dom +``` + + +AI Transport does not depend on Next.js. This guide uses it because the agent route has to keep streaming after the HTTP response returns, which Next.js provides through `after()`, and any server that can do the same works unchanged. + +## Set up authentication + +Create an auth endpoint at `/api/auth/token` that returns an Ably JWT to the client. The endpoint validates the user and signs a token with their client ID and the channel capabilities they need. See [Set up authentication](/docs/ai-transport/getting-started/authentication) for the full setup. + +The client below uses `authUrl: '/api/auth/token'` to fetch tokens from this endpoint. + +## Configure the channel rule + +AI Transport streams each response by appending tokens to a single channel message. That requires the **Message annotations, updates, deletes, and appends** channel rule (`mutableMessages`) on the namespace your conversations live on. + +In your Ably dashboard, enable **Message annotations, updates, deletes, and appends** on the `conversations` namespace. See [Configure the channel rule](/docs/ai-transport/getting-started/channel-rules) for the dashboard, Control API, and CLI steps. + + + +## Create the agent route + +Create `app/api/chat/route.ts`. The agent receives an [invocation](/docs/ai-transport/concepts/runs#invocations), creates an [agent session](/docs/ai-transport/api/javascript/core/agent-session) bound to the `ResponsesCodec`, starts a [run](/docs/ai-transport/concepts/runs), rebuilds the conversation, opens a Responses stream, pipes it, and ends the run: + + +```javascript +import { after } from 'next/server'; +import OpenAI from 'openai'; +import * as Ably from 'ably'; +import { createAgentSession, Invocation } from '@ably/ai-transport'; +import { ResponsesCodec, toResponsesInput } from '@ably/ai-transport/openai'; + +const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY }); +const openai = new OpenAI(); + +export async function POST(req) { + const invocation = Invocation.fromJSON(await req.json()); + + const session = createAgentSession({ + client: ably, + channelName: invocation.sessionName, + codec: ResponsesCodec, + }); + + await session.connect(); + + // No identity is pinned: this run is not retried, so a generated run id and + // invocation id are correct. + const run = session.createRun(invocation, {}, { signal: req.signal }); + + // Load the full conversation from history before starting the run. + // run.start() needs to have seen this run's triggering input; draining it + // from history means run.start() resolves at once instead of waiting for + // that input to arrive live on the channel. + while (run.view.hasOlder()) { + await run.view.loadOlder(); + } + + // Start before returning the response. A continuation re-keys run.runId from + // the triggering input's headers, so the id is only final once start resolves. + await run.start(); + + // Flatten the conversation into the Responses `input` array. Each stored + // message already holds valid model input, so no conversion is needed. + const input = toResponsesInput(run.view.getMessages().map(({ message }) => message)); + + after(async () => { + try { + const stream = await openai.responses.create( + { model: 'gpt-5.5', input, stream: true }, + { signal: run.abortSignal }, + ); + + // The Responses stream is an async iterable, and each raw event is valid + // codec output, so pipe it straight through. run.pipe watches + // run.abortSignal, so a cancel ends the run with no extra handling here. + const { reason } = await run.pipe(stream); + + await run.end({ reason }); + } catch (err) { + await run.end({ reason: 'error' }); + throw err; + } finally { + await session.end(); + } + }); + + return Response.json({ runId: run.runId, invocationId: run.invocationId }); +} +``` + + + + +This route handles a single model turn. To configure server-side tools and run the agentic loop (model turn, run tools, continue), see [OpenAI Responses](/docs/ai-transport/frameworks/openai). + +## Create the chat component + +Create `app/chat.tsx`. The component reads the conversation with [`useView`](/docs/ai-transport/api/react/core/use-view) and sends a user turn with `ResponsesCodec.createUserMessage`. Because the core SDK never sends HTTP itself, the component POSTs to the agent endpoint after `view.send` resolves. + +An OpenAI message holds a list of `items`, so rendering flattens each message's text content parts: + + +```javascript +'use client'; + +import { useState } from 'react'; +import { useClientSession, useView } from '@ably/ai-transport/react'; +import { ResponsesCodec } from '@ably/ai-transport/openai'; + +async function wakeAgent(run) { + await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(run.toInvocation().toJSON()), + }); +} + +// Flatten an OpenAI message's items into rendered text. +function messageText(message) { + let text = ''; + for (const item of message.items) { + if (item.type !== 'message') continue; + for (const part of item.content) { + if (part.type === 'output_text' || part.type === 'input_text') text += part.text; + } + } + return text; +} + +export function Chat() { + const [input, setInput] = useState(''); + + const { session } = useClientSession(); + const view = useView({ limit: 30 }); + const { messages, runOf } = view; + + // The Stop button shows only while the latest message's Run is streaming. + const latestRun = runOf(messages.at(-1)?.codecMessageId ?? ''); + const isStreaming = latestRun?.status === 'active'; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!input.trim()) return; + const text = input; + setInput(''); + const run = await view.send( + ResponsesCodec.createUserMessage({ + role: 'user', + items: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text }] }], + }), + ); + await wakeAgent(run); + }; + + const stop = () => { + if (latestRun) void session.cancel(latestRun.runId); + }; + + return ( +
+ {messages.map(({ codecMessageId, message }) => ( +
+ {message.role}: {messageText(message)} +
+ ))} +
+ setInput(e.target.value)} placeholder="Type a message..." /> + {isStreaming ? ( + + ) : ( + + )} +
+
+ ); +} +``` +
+ +## Wire it together
+ +Create `app/page.tsx`. `Providers` sets up an authenticated Ably client. [`ClientSessionProvider`](/docs/ai-transport/api/react/core/providers) binds the channel and the `ResponsesCodec` into AI Transport. `ResponsesCodec` is a ready-made codec, so you pass it directly with no factory call. + +Update `channelName` to match a namespace with the AIT [channel rule](/docs/ai-transport/getting-started/channel-rules) configured: + + +```javascript +'use client'; + +import { useEffect, useState } from 'react'; +import * as Ably from 'ably'; +import { AblyProvider } from 'ably/react'; +import { ClientSessionProvider } from '@ably/ai-transport/react'; +import { ResponsesCodec } from '@ably/ai-transport/openai'; +import { Chat } from './chat'; + +function Providers({ children }) { + const [client, setClient] = useState(null); + + useEffect(() => { + const ably = new Ably.Realtime({ authUrl: '/api/auth/token', clientId: 'user' }); + setClient(ably); + return () => ably.close(); + }, []); + + if (!client) return null; + return {children}; +} + +export default function Page() { + const channelName = 'conversations:my-chat-session'; + return ( + + + + + + ); +} +``` + + +Run `npm run dev` and open `http://localhost:3000`. Open a second tab to the same URL; both tabs share the same durable session. + +## What happens when you send a message + +1. The user types a message. `view.send(...)` publishes the message on the channel and returns a `ClientRun`. The SDK does not POST to your agent endpoint itself. +2. Your client code calls `clientRun.toInvocation().toJSON()` and POSTs the resulting [`InvocationData`](/docs/ai-transport/concepts/runs#invocations) to your agent endpoint. The body identifies the session and the input event the agent should respond to. +3. The agent endpoint creates an `AgentSession` bound to the `ResponsesCodec` and starts a Run. `run.start()` waits until it has seen the triggering input on the channel, whether loaded from history or arriving live. +4. The agent reads the full conversation from `run.view` and calls `toResponsesInput` to build the Responses `input` array. Each stored message is already valid Responses input. +5. The agent opens a streaming Responses API call and pipes the event stream through `run.pipe()`, which encodes each Responses event onto the channel. +6. Every client subscribed to the channel decodes the streamed events in realtime. `useView` re-renders as the visible Run accumulates OpenAI items. +7. If a client disconnects mid-stream, Ably resumes the subscription from the last serial on reconnect; the SDK rehydrates the view without losing tokens. + +## Understand the architecture + +The OpenAI SDK handles the model call and the typed event stream. AI Transport handles the durable session between agent and devices, encoding the Responses stream onto an Ably channel through the `ResponsesCodec`. See [OpenAI Responses](/docs/ai-transport/frameworks/openai) for how the codec works and how to run server-side tools. + +## Explore next + +- [OpenAI Responses](/docs/ai-transport/frameworks/openai): the codec, `toResponsesInput`, and the server-side tool loop. +- [OpenAI demo app](https://github.com/ably/ably-ai-transport-js/tree/main/demo/openai/react/use-client-session): the runnable version of this app, with client-side tools and approval gates added. +- [ResponsesCodec reference](/docs/ai-transport/api/javascript/openai/codec): the codec's methods, tool payloads, and types. +- [Conversation helpers reference](/docs/ai-transport/api/javascript/openai/conversation-helpers): `toResponsesInput` and the correlation readers. +- [Cancellation](/docs/ai-transport/features/cancellation): the stop button pattern and the agent-side `onCancel` authorisation hook. +- [Multi-device sessions](/docs/ai-transport/features/multi-device): open another tab to see realtime sync. +- [Branching, edit, and regenerate](/docs/ai-transport/features/branching): fork the conversation and navigate alternative branches. +- [Codec architecture](/docs/ai-transport/internals/codec-architecture): how a codec translates a framework's events into Ably messages. diff --git a/src/pages/docs/ai-transport/getting-started/temporal.mdx b/src/pages/docs/ai-transport/getting-started/temporal.mdx index d571a7020d..87935babbb 100644 --- a/src/pages/docs/ai-transport/getting-started/temporal.mdx +++ b/src/pages/docs/ai-transport/getting-started/temporal.mdx @@ -27,7 +27,7 @@ Install the dependencies: ```shell -npm install @ably/ai-transport@^0.6.0 ably ai@^6 \ +npm install @ably/ai-transport@^0.7.0 ably ai@^6 \ @ai-sdk/react@^3 @ai-sdk/anthropic@^3 \ @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity \ next react react-dom zod jsonwebtoken dotenv @@ -103,7 +103,6 @@ import type { InvocationData } from '@ably/ai-transport'; export interface RunIds { runId: string; invocationId: string; - triggerEventId: string; } export interface ChatWorkflowInput { @@ -238,6 +237,7 @@ export async function openRun(input: { // Pin the run id to the Temporal workflowId so a fresh-process retry of // openRun re-enters the same run instead of opening a parallel one. runId: input.invocationId, + }, { signal: Context.current().cancellationSignal, }); while (run.view.hasOlder()) await run.view.loadOlder(); @@ -249,7 +249,6 @@ export async function openRun(input: { return { runId: run.runId, invocationId: run.invocationId, - triggerEventId: input.invocation.inputEventId, }; } finally { ably.close(); @@ -261,7 +260,9 @@ export async function runInferenceStep(input: StepInput): Promise { try { diff --git a/src/pages/docs/ai-transport/getting-started/vercel-wdk.mdx b/src/pages/docs/ai-transport/getting-started/vercel-wdk.mdx index 2a6304dfb4..bf0530d8b2 100644 --- a/src/pages/docs/ai-transport/getting-started/vercel-wdk.mdx +++ b/src/pages/docs/ai-transport/getting-started/vercel-wdk.mdx @@ -169,7 +169,6 @@ import { tools } from './tools'; export interface TurnIds { runId: string; invocationId: string; - triggerEventId: string; } export interface ToolCallInfo { @@ -223,7 +222,7 @@ export async function openRun(invocationData: InvocationData, workflowRunId: str while (run.view.hasOlder()) await run.view.loadOlder(); await run.start(); - return { runId: run.runId, invocationId, triggerEventId: invocation.inputEventId }; + return { runId: run.runId, invocationId }; }); } @@ -233,7 +232,7 @@ export async function runInference(invocationData: InvocationData, ids: TurnIds) const invocation = Invocation.fromJSON(invocationData); return withAgentSession(invocation.sessionName, async (session) => { const { stepId } = getStepMetadata(); - const run = session.adoptRun(ids); + const run = session.adoptRun(invocation, ids); await run.load({ timeoutMs: 15_000 }); while (run.view.hasOlder()) await run.view.loadOlder(); @@ -279,7 +278,7 @@ export async function runTool(invocationData: InvocationData, ids: TurnIds, tool 'use step'; const invocation = Invocation.fromJSON(invocationData); await withAgentSession(invocation.sessionName, async (session) => { - const run = session.adoptRun(ids); + const run = session.adoptRun(invocation, ids); await run.load({ timeoutMs: 15_000 }); const step = run.createStep({ stepId: getStepMetadata().stepId }); @@ -299,7 +298,7 @@ export async function failRun(invocationData: InvocationData, ids: TurnIds, erro 'use step'; const invocation = Invocation.fromJSON(invocationData); await withAgentSession(invocation.sessionName, async (session) => { - const run = session.adoptRun(ids); + const run = session.adoptRun(invocation, ids); try { await run.load({ timeoutMs: 15_000 }); } catch { diff --git a/src/pages/docs/ai-transport/internals/codec-architecture.mdx b/src/pages/docs/ai-transport/internals/codec-architecture.mdx index 450763ac7c..6d55eafe4d 100644 --- a/src/pages/docs/ai-transport/internals/codec-architecture.mdx +++ b/src/pages/docs/ai-transport/internals/codec-architecture.mdx @@ -62,7 +62,7 @@ AI Transport separates concerns into two layers: - Transport layer (generic): manages run identity, lifecycle events, cancellation, input-event lookup, history pagination, and multi-client sync. It works with any `TInput` and `TOutput`. - Codec layer (domain): maps framework-specific events to Ably publish operations and back. It knows the shape of your events; it does not manage run lifecycle. -The transport layer calls into the codec but never inspects the domain payload. The codec calls into the channel writer but never manages runs or lifecycle. The separation makes AI Transport framework-agnostic; the Vercel codec is one implementation, and any framework's event shape can be implemented against the same `Codec` interface. +The transport layer calls into the codec but never inspects the domain payload. The codec calls into the channel writer but never manages runs or lifecycle. The separation makes AI Transport framework-agnostic. The SDK bundles a [Vercel codec](/docs/ai-transport/api/javascript/vercel/codec) for the Vercel AI SDK and a [`ResponsesCodec`](/docs/ai-transport/frameworks/openai) for the OpenAI Responses API, and any other framework's event shape can be implemented against the same `Codec` interface. ## TInput and TOutput diff --git a/src/pages/docs/ai-transport/internals/conversation-tree.mdx b/src/pages/docs/ai-transport/internals/conversation-tree.mdx index c7f5041bd4..6cb226afbf 100644 --- a/src/pages/docs/ai-transport/internals/conversation-tree.mdx +++ b/src/pages/docs/ai-transport/internals/conversation-tree.mdx @@ -86,6 +86,8 @@ Earlier versions of the SDK described a "winner / precedence" model on the Tree. The result is ordered oldest-first by serial. It is a single-element array when the node has no siblings, and an empty array when the key is unknown. +The tree excludes superseded runs from the group. A run named by another message's `supersedes` header is a dead trunk that a fork already resolved, so it is not a selectable branch. The same exclusion applies to the tree's visible nodes and to a node's reply runs, so one client's single tool answer renders as one linear reply. + The View consumes this to compute `branchSelection(codecMessageId)`, which returns a `BranchHandle` (`hasSiblings`, `siblings`, `index`, `selected`, `select`). The View's per-instance `selections` map records the selected sibling at each anchor; the handle's `select(index)` updates it and emits an `'update'` event. ## Walk the tree diff --git a/src/pages/docs/ai-transport/internals/transport-patterns.mdx b/src/pages/docs/ai-transport/internals/transport-patterns.mdx index 0c3e4fc741..864897414a 100644 --- a/src/pages/docs/ai-transport/internals/transport-patterns.mdx +++ b/src/pages/docs/ai-transport/internals/transport-patterns.mdx @@ -76,7 +76,7 @@ The sequence: Continuation: existing run-id stamped on the input from clientRun.runId. 2. Client POSTs InvocationData ({ inputEventId=E1, sessionName }) to the agent. 3. Agent route receives the POST. -4. Agent calls session.createRun(invocation, runtime). createRun mints the +4. Agent calls session.createRun(invocation, identity, hooks). createRun generates the invocationId, arms the input-event watcher (a passive Tree pre-scan plus a listener), and registers the run for cancel routing so early cancels fire. 5. Agent calls run.start(). It awaits run.located: the watcher matches E1 diff --git a/src/pages/docs/ai-transport/internals/wire-protocol.mdx b/src/pages/docs/ai-transport/internals/wire-protocol.mdx index 959bdf4fee..9234e05769 100644 --- a/src/pages/docs/ai-transport/internals/wire-protocol.mdx +++ b/src/pages/docs/ai-transport/internals/wire-protocol.mdx @@ -25,7 +25,7 @@ Agent publishes step-start Agent publishes assistant stream create name: ai-output - extras.ai.transport: { run-id=R1, invocation-id=I1, codec-message-id=M2, role=assistant, parent=M1, input-codec-message-id=M1, step-id=SP1, start-serial= } + extras.ai.transport: { run-id=R1, invocation-id=I1, codec-message-id=M2, role=assistant, parent=M1, input-codec-message-id=M1, step-id=SP1, step-start-serial= } extras.ai.codec: { stream=true, stream-id=S1, status=streaming } Agent appends text deltas to M2 @@ -69,10 +69,11 @@ Transport headers carry the routing and identity that the transport layer reads. | `parent` | The `codec-message-id` of the immediately preceding message in this branch. | | `fork-of` | The `codec-message-id` this message replaces. Present on edits. | | `msg-regenerate` | The `codec-message-id` of the assistant message this run regenerates. Stamped on the regenerate input and echoed on `ai-run-start`. | +| `supersedes` | The `run-id` of a run whose output the run this message opens replaces. A client answering a tool call sets it to the suspended run it forked away from, and the tree excludes that run from branch selection, so one client's single response renders as one linear reply while concurrent forks from several clients still show as sibling branches. `fork-of` and `msg-regenerate` differ in that they create a navigable sibling and keep both visible. | | `run-reason` | The reason a run ended. Present on `ai-run-end`. One of `complete`, `cancelled`, `error`. A suspended run uses the `ai-run-suspend` event instead and is not terminal. | | `step-id` | Step correlation. Stable across retry attempts of the same [step](/docs/ai-transport/concepts/runs#steps): a retry publishes `ai-step-start` under the same `step-id` and the retry's output supersedes the failed attempt. Set on `ai-step-start`, `ai-step-end`, and every `ai-output` published inside the step. | | `step-client-id` | The Ably `clientId` attributed to a step. Sticky across steps in a run by default; a mid-run steering message that incorporates a fresh input overrides it. | -| `start-serial` | The Ably serial of the step's own `ai-step-start`. Stamped on every `ai-output` inside the step so the tree can elect the latest attempt when two attempts share a `step-id`. | +| `step-start-serial` | The Ably serial of the step's own `ai-step-start`. Stamped on every `ai-output` inside the step so the tree can elect the latest attempt when two attempts share a `step-id`. | | `step-reason` | The reason a step ended. Present on `ai-step-end`. One of `complete`, `failed`, `cancelled`. | | `error-code` | Numeric error code on `ai-run-end` with `run-reason: error`. | | `error-message` | Human-readable error message on `ai-run-end` with `run-reason: error`. | @@ -101,17 +102,13 @@ The following header constants are exported from `@ably/ai-transport` as `HEADER | `HEADER_FORK_OF` | `'fork-of'` | | `HEADER_MSG_REGENERATE` | `'msg-regenerate'` | | `HEADER_RUN_REASON` | `'run-reason'` | -| `HEADER_STEP_ID` | `'step-id'` | -| `HEADER_STEP_CLIENT_ID` | `'step-client-id'` | -| `HEADER_START_SERIAL` | `'start-serial'` | -| `HEADER_STEP_REASON` | `'step-reason'` | | `HEADER_ERROR_CODE` | `'error-code'` | | `HEADER_ERROR_MESSAGE` | `'error-message'` | | `HEADER_STREAM` | `'stream'` | | `HEADER_STREAM_ID` | `'stream-id'` | | `HEADER_STATUS` | `'status'` | -The remaining wire headers (`invocation-id`, `event-id`, `input-codec-message-id`, `discrete`) are SDK internals and are not exported as constants. The SDK reads and writes them through the `getTransportHeaders` and `getCodecHeaders` utilities. +The remaining wire headers (`invocation-id`, `event-id`, `input-codec-message-id`, `discrete`, `supersedes`, `steer-codec-message-ids`, `step-id`, `step-client-id`, `step-start-serial`, `step-reason`) are SDK internals and are not exported as constants. The SDK reads and writes them through the `getTransportHeaders` and `getCodecHeaders` utilities. ## Event names