Codec architecture

Internal architecture of the codec: the input/output type split, encoder and decoder pipelines, how the codec composes with the transport layer, and how to write one with defineCodec.

The codec translates between your AI framework's event types and Ably channel messages. The interface is generic over four type parameters: TInput (client-published events), TOutput (agent-published events), TProjection (the per-run reduced state), and TMessage (the rendered domain message). The two-direction split is type-system-enforced: an encoder can publish a TInput (publishInput) or a TOutput (publishOutput), and a decoder returns { inputs, outputs } so the consumer can branch on direction without re-checking each event.

The two pipelines mirror each other:

Publish side (agent): TOutput events from streamText / your model │ ▼ Encoder.publishOutput │ ▼ ChannelWriter ── channel.publish / appendMessage / updateMessage │ ▼ Ably channel (ai-output) Publish side (client): TInput events from view.send / regenerate / edit │ ▼ Encoder.publishInput │ ▼ ChannelWriter ── channel.publish (discrete) │ ▼ Ably channel (ai-input) Subscribe side (both): Ably channel │ ▼ Decoder.decode ── DecodedMessage<TInput, TOutput> │ ▼ Tree ── Codec.fold per node (TProjection on InputNode or RunNode) │ ▼ Codec.getMessages(projection) ── CodecMessage<TMessage>[] │ ▼ View
Copied!

Two-layer split

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 two layers stay separate, so AI Transport works with any AI framework. The SDK bundles a Vercel codec for the Vercel AI SDK and a ResponsesCodec for the OpenAI Responses API, and you can implement any other framework's event shape against the same Codec interface.

TInput and TOutput

The codec is bidirectional with separate type bases:

  • CodecInputEvent is the base for everything a client publishes. The SDK defines UserMessage, Regenerate, ToolResult, ToolResultError, and ToolApprovalResponse as well-known input variants on top of this base.
  • CodecOutputEvent is the base for everything an agent publishes: text deltas, tool calls, reasoning, file or source parts, data chunks.

Both bases carry a kind discriminator the codec reads to dispatch. Inputs additionally carry routing fields (codecMessageId, parent, target) that the encoder writes to the wire as transport headers.

Decoder.decode(message) returns a DecodedMessage<TInput, TOutput> tagged with which direction the message was carried on. Consumers branch on the tag rather than re-classifying.

Well-known input variants

The SDK defines a set of TInput variants that appear in every framework. Each variant is a tagged union identified by its kind field. A codec must support these so the SDK's higher-level methods (view.send, view.regenerate, view.edit) can call into them without a per-framework adapter:

Variantkind valueWhat it triggers
UserMessage<TMessage>'user-message'A fresh user turn.
Regenerate'regenerate'Regenerate an assistant message.
ToolResult<TPayload>'tool-result'Deliver a successful tool result back to the agent.
ToolResultError<TPayload>'tool-result-error'Deliver a tool failure.
ToolApprovalResponse<TPayload>'tool-approval-response'Approve or deny a pending tool call.

The tool variants carry a codec-defined payload. The core knows only the routing (kind, codecMessageId) and lets the codec own the shape of the payload, such as the Vercel layer's { toolCallId, output } for tool-result.

Edits go on the wire as a fresh UserMessage published with view.edit, which routes through the forkOf header on the user-message path, so there is no separate edit variant. A custom codec adds its own variants on top, using any kind value other than the five reserved above.

Encoder

The encoder converts outbound events into Ably publish operations. It exposes four methods on the Encoder interface:

MethodPurpose
publishInput(input, options?)Publish a single TInput event on ai-input. Sets the codec-message-id and merges per-write headers under extras.ai.transport.
publishOutput(output, options?)Publish a single TOutput event on ai-output. Streams when the codec marks the event as appendable; otherwise publishes discretely.
cancelStreams()Close any in-flight streams with status: 'cancelled' and flush pending appends. Idempotent, and throws if the encoder is already closed.
close()Flush pending appends, drop the stream trackers, release resources. close() writes no terminal append, so call cancelStreams() first to close a stream still open on the wire.

Streamed mode

For events the codec marks as appendable (text deltas, reasoning deltas), the encoder runs a three-step pipeline per stream, exposed through the encoder core as startStream, appendStream, and closeStream:

  1. startStream(streamId, payload) calls channel.publish with stream: 'true', status: 'streaming', and the supplied stream-id. The publish returns a serial; the encoder core retains it for subsequent appends.
  2. Each subsequent appendable delta calls appendStream(streamId, data), which fire-and-forgets channel.appendMessage with the captured serial.
  3. The terminal event calls closeStream(streamId, payload), which writes a final channel.appendMessage with status: 'complete' (or cancelAllStreams() writes status: 'cancelled' on every stream still open).

Per-token append calls do not block the encoder. The encoder collects every append promise and awaits them as a batch when the stream closes. If any append rejected, the encoder writes a recovery channel.updateMessage containing the full accumulated payload, so subscribers see the intended final state even when intermediate appends were lost.

Discrete mode

Client publishes (publishInput) and a codec's own discrete output events use discrete mode, which publishes without appends. One channel.publish can carry several events, so a multi-part user message goes out as a single batched publish. The RunManager publishes run and step lifecycle events directly, and those never reach the encoder. Discrete events still carry the codec payload and any per-event headers. Codecs that need to publish several discrete messages atomically use publishDiscreteBatch from the encoder core to send them in a single channel publish.

Decoder

The decoder converts inbound Ably messages back into TInput or TOutput events. It dispatches on the inbound message's Ably action.

ActionMeaningDecoder behaviour
message.createA new message arrivedBegin a new stream (when stream: 'true') or pass the payload to the codec's discrete decode hook.
message.appendA token was appendedEmit a delta for the matching stream-id; emit end events when the append carries status: 'complete'.
message.updateA message was replacedPrefix-match against the tracked stream. If the new data extends what the decoder has accumulated, emit the delta. If it does not, the update is a replacement: the decoder overwrites its accumulated state and headers and emits no events. An update for a stream the decoder has never seen takes the first-contact path instead.
message.deleteA message was removedClear the stream's tracker state.

The decoder maintains a stream tracker that maps each Ably channel serial to that stream's current state. A subscriber that joins mid-stream sees a message.update or message.append for a serial it has never seen the message.create for; the decoder treats the incoming state as the current full state and accumulates from there. The end state matches that of a subscriber that received every operation.

decode() returns a DecodedMessage<TInput, TOutput>. The Tree feeds each decoded event through Codec.fold(projection, event) to update the owning run's TProjection. Codec.getMessages(projection) materialises the projection into CodecMessage<TMessage>[]: each entry pairs the domain message with the SDK's client-generated codecMessageId. The View concatenates these across the visible run chain and exposes the pair list as getMessages(). There is no second accessor returning the domain objects on their own; callers that only need those map .message.

Header tiers

Transport headers live under extras.ai.transport. The tier covers run and message identity (run-id, codec-message-id, role, parent, fork-of, and others) and the stream lifecycle the encoder and decoder manage (stream, stream-id, status, discrete).

Codec headers live under extras.ai.codec, and that tier is omitted entirely when a message has no codec headers. It holds kind, which the decoder dispatches on, partType for the parts of an exploded batch, and whatever fields the codec declares on each descriptor. Both kind and partType are reserved, so binding a descriptor field to either name throws when the codec is defined.

The split means a codec declares payload identity while the transport owns routing, so a custom codec cannot write a run-id by accident. The stream lifecycle headers are on the transport tier because the transport, rather than the codec, decides when a message streams.

mergeHeaders, getTransportHeaders, and getCodecHeaders from @ably/ai-transport are utility helpers for working with the tier structure.

Write a custom codec

To support a framework the SDK does not bundle, describe your event types to defineCodec and it builds the encoder and decoder for you. You declare what each event looks like on the wire, and the generic drivers handle dispatch, stream tracking, and header discipline. Both bundled codecs are built this way.

A codec is shared code: the same implementation encodes on the agent and decodes on the client.

defineCodec is curried once. The call fixes your input and output unions explicitly, and the config that follows is the descriptor tables:

JavaScript

1

2

3

4

5

6

import { defineCodec } from '@ably/ai-transport';

const codec = defineCodec()({
  output: ({ event, stream, drop }) => [/* output descriptors */],
  input: ({ event, batch }) => [/* input descriptors */],
});

defineCodec returns a WireCodec: createEncoder, createDecoder, and the adapter tag. There is no reducer and no message extraction, because those belong to the session layer, which is assembled by a separate factory the SDK does not currently export.

The config takes four fields:

FieldRequiredPurpose
outputyesA function receiving { event, stream, drop } that returns the descriptor table for everything the agent publishes.
inputyesA function receiving { event, batch } that returns the descriptor table for everything a client publishes.
adapterTagnoAn identifying tag for the codec.
decoderSynthesiseLifecyclenoA factory returning a lifecycle policy. Called once per createDecoder(), so the policy keeps its own per-decoder state.

Describe outputs

The output builder gives you three entry kinds. event(type, spec?) declares a discrete publish, stream(kind, spec) declares a streamed group of start, delta, and end chunks, and drop(type) declares a chunk that produces no wire traffic at all.

A discrete event spec is optional. event('start-step') on its own is valid, and the type literal becomes the kind header the decoder dispatches on. Where an event carries data, fields binds header values and data encodes the body:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

// Agent-published outputs, as passed to defineCodec.
const fId = strField('id');
const fMeta = jsonField('providerMetadata');

output: ({ event, stream, drop }) => [
  stream('text', {
    streamId: (chunk) => chunk.id,
    fields: [fId, fMeta],
    start: { type: 'text-start' },
    delta: { type: 'text-delta', field: 'delta', decode: ({ rebuild }) => rebuild([fId]) },
    end: { type: 'text-end' },
  }),
  event('message-metadata', { fields: [jsonField('messageMetadata')] }),
  event('data-*', { fields: [fId], data: { encode: (c) => c.data, decode: (d) => ({ data: d }) } }),
  drop('start-step'),
],

A stream group requires streamId, fields, start, delta, and end. Two details matter when you write one. The delta.field must be the key of a string-valued property of the delta chunk, because that is the fragment the encoder appends. The end spec is a discriminated pair, so supplying encode without a matching decode fails to compile.

Three optional pieces cover the harder cases:

  • start.match lets several groups share one start chunk type. The encoder picks the first group whose match returns true for the chunk.
  • end.decode receives the accumulated text and both the opening and closing headers, so the end chunk can reconstruct a value the deltas built up.
  • decodeDiscrete on a stream group handles the same content arriving as a single non-streamed message, which is what history compaction produces.

A -* suffix makes an output type a wildcard family, so data-* matches data-weather. Wildcards are legal on an output event type and on a batch part type, and nowhere else.

Describe inputs

The input builder gives you event(kind, spec?) and batch(kind, spec).

An input's fields and data are scoped to the event's payload, because the driver wraps and unwraps the { kind, codecMessageId, payload } envelope for you. An input that carries no payload at all is wireOnly, which sets the kind header and decodes to nothing. A regenerate is the canonical example, because the parent and target it needs are transport headers rather than payload.

batch covers a client message that fans out into several wire events, such as a message carrying both text and file parts. It takes explode to split the input into parts, partTypeOf to give each part its type, parts to describe each part type, assemble to rebuild a part into an event, and optionally messageHeaders for headers every part repeats.

Fail at build time rather than on the wire

defineCodec validates the tables once, when you define the codec, and throws InvalidArgument there and then, so a broken table never becomes a codec that misbehaves on the wire. It rejects a duplicate wire kind across events and stream groups, a chunk type claimed by more than one encode path (so you cannot both event('x') and drop('x')), a stream start type that collides with another group's delta or end phase, duplicate input kinds, duplicate part types inside one batch, and any field bound to the reserved kind or partType names.

Sharing a start type across groups is legal by design, which is what start.match resolves.

At encode time, an output whose type is neither described nor dropped throws InvalidArgument. Your table therefore has to cover every chunk your framework emits, with drop for the ones you deliberately ignore.

The fields you pass are checked at compile time too. FieldFor<C> forces a field's key to be a real property of the event and the field's value type to match that property's type, so a misspelled key or a boolean field bound to a string property fails to compile instead of quietly omitting the header.

Bind headers with fields

A HeaderField binds one header key to one value type, so the key cannot drift between the encode and decode sides. Four constructors cover the value types:

ConstructorReads asNotes
strField(key, fallback?)StringSupplying a fallback makes read total.
boolField(key, fallback?)BooleanSupplying a fallback makes read total.
jsonField(key)Parsed JSONNever total. Malformed JSON reads as undefined.
enumField(key, values, fallback)One of valuesAlways total. An absent or unrecognised header reads as the fallback.

A write whose value fails the field's type check is skipped, and the key reads back as absent or as the fallback. enumField is the exception: it writes any string without checking it against the allow-list, so validation happens on read.

The descriptor drivers move every declared field for you, writing it on encode and reading it back on decode. A key that reads undefined is omitted instead of set, because an absent header and an explicit undefined are indistinguishable on the wire. stripUndefined from @ably/ai-transport applies the same rule to an object you rebuild inside a decode hook.

Choose the well-known factories

A session codec also selects which of the core's five well-known input factories it exposes. createUserMessage and createRegenerate are mandatory, and each tool factory is only offered when the input union carries the matching variant, so the types stop a codec exposing a factory it cannot encode. Both bundled session codecs pass the full set through.

This is a session-layer concern, so a defineCodec wire codec has no say in it.

Reducer contract

Your reducer merges unconditionally. It must not keep a serial high-water-mark and must not skip an event it has seen before, because ordering, deduplication, and replay all belong to the transport.

The transport calls fold exactly once per event in canonical order. When a late wire message would otherwise merge out of order, the tree discards the node's state, calls init() again, and replays every event it has logged for that node. A reducer holding a high-water-mark would discard the whole replay and leave the node empty. Last-writer-wins falls out of merge order, since the highest-serial event merges last.

Use the cores directly

createEncoderCore and createDecoderCore remain available for a codec that needs to replace createEncoder or createDecoder outright, which defineCodec gives no hook for. The encoder core exposes publishDiscrete, publishDiscreteBatch, startStream, appendStream, closeStream, cancelAllStreams, and close. They are worth it only when a descriptor table genuinely cannot express your framework's shape, since you then own dispatch and validation yourself.

A complete minimal codec

One output event and one input event, which is the whole of a minimal wire codec:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

// Shared by the agent and the client.
import { defineCodec } from '@ably/ai-transport';

const codec = defineCodec()({
  output: ({ event }) => [
    event('reply', {
      fields: [],
      data: { encode: (chunk) => chunk.text, decode: (d) => ({ text: String(d) }) },
    }),
  ],
  // A single event with no fields or data rebuilds to the
  // { kind, codecMessageId, payload } envelope.
  input: ({ event }) => [event('noop')],
});

For a production-scale table, read the bundled codecs. The Vercel codec's outputs cover three stream groups, fifteen discrete events, and a data-* wildcard, and the OpenAI codec's descriptors show three groups sharing one start type resolved by match, composite stream ids, and nine dropped types.