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 separation makes AI Transport framework-agnostic. The SDK bundles a Vercel codec for the Vercel AI SDK and a ResponsesCodec for the OpenAI Responses API, and any other framework's event shape can be implemented 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 Vercel codec 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 stamps onto 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. Stamps 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, close active streams, release resources.

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 cancelStream(streamId) writes status: 'cancelled').

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 lifecycle events use discrete mode: one channel.publish per event, no appends. 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 hand 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 Ably channel serial to its 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 is the same as 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-minted 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 drive (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 sits 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 in two stages. The first call fixes your input and output unions explicitly, and the second infers TProjection and TMessage from the reducer you supply:

JavaScript

1

2

3

4

5

6

7

8

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

const codec = defineCodec()({
  reducer: { init, fold, getMessages },
  output: ({ event, stream, drop }) => [/* output descriptors */],
  input: ({ event, batch }) => [/* input descriptors */],
  factories: (base) => base,
});

The config takes six fields:

FieldRequiredPurpose
reduceryesinit, fold, and getMessages. Folds events into the per-run TProjection and materialises it as messages. fold receives each event direction-tagged, so it reads event.event and can branch on event.direction.
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.
factoriesyesA function receiving the core's well-known input factories and returning the subset your input union supports.
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 name 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 stamps 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 name each part, 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, not 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 names 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 name a real property of the event and 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 whole bags of fields for you, writing each declared field 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

factories receives the core's five well-known input factories and returns the subset your input union supports. createUserMessage and createRegenerate are mandatory, and each tool factory is only offered when your TInput carries the matching variant, so the types stop you exposing a factory your codec cannot encode.

A full codec passes them straight through with factories: (base) => base. A text-only codec returns just the two mandatory factories and still satisfies the Codec interface.

Reducer contract

Your reducer folds 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 land 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 fold order, since the highest-serial event folds last.

Drop to the cores

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, cancelStream, and cancelAllStreams. 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, one input event, and a reducer that records what it folds:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

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

const codec = defineCodec()({
  reducer: {
    init: () => ({ folded: [] }),
    fold: (state, event) => {
      state.folded.push(event.event);
      return state;
    },
    getMessages: (state) =>
      state.folded.map((message, i) => ({ codecMessageId: `cm-${String(i)}`, message })),
  },
  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')],
  factories: (base) => ({
    createUserMessage: base.createUserMessage,
    createRegenerate: base.createRegenerate,
  }),
});

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