Encoders
Render a run in another wire format, chosen by whoever asked for the run. AG-UI ships as a worked example.
An encoder is the one kind that is not part of a flow at all. Every other kind answers "what runs inside a flow"; this one answers "what the run looks like on the way out". It is a sink on the event stream: one runner event in, zero or more wire frames out.
Whoever asked for the run chooses it. Not the spec, and not the operator: two clients hitting the same flow can legitimately want different renderings of it, and neither the flow's author nor whoever runs the server knows which.
Over HTTP that is the request, with ?protocol=:
POST /v1/runs?stream=true&protocol=ag-uiOn the CLI it is --protocol, and the frames go to stdout one JSON object per line:
heddle run examples/ag-ui/flow.json --plugin ./examples/ag-ui/encoder.json \
--protocol ag-ui --input '{"query": "hello"}'{"data":{"type":"RUN_STARTED","threadId":"5d4ed795-…","runId":"5d4ed795-…"}}
{"data":{"type":"STEP_STARTED","stepName":"start"}}
{"data":{"type":"STEP_FINISHED","stepName":"start"}}
{"data":{"type":"STATE_SNAPSHOT","snapshot":{"query":"hello"}}}
{"data":{"type":"STEP_STARTED","stepName":"end"}}
{"data":{"type":"STEP_FINISHED","stepName":"end"}}
{"data":{"type":"STATE_SNAPSHOT","snapshot":{"query":"hello"}}}
{"data":{"type":"RUN_FINISHED","threadId":"5d4ed795-…","runId":"5d4ed795-…"}}The two differ only in framing, which belongs to the transport rather than the encoder: SSE
carries a frame's name on an event: line because it is an HTTP response body, and the CLI
writes the frame itself so a nameless frame stays nameless. encode returns the same frames
either way, so an encoder can be written and checked without starting a server at all. See
the CLI reference for what the
CLI does with the final state.
Omit the choice, or ask for heddle, and you get heddle's own frames. A plugin may not
claim that name, so a client asking for it always gets the frames documented in
the server API.
Writing one
{
"componentType": "AgUiEncoder",
"kind": "encoder",
"protocol": "ag-ui",
"contentType": "text/event-stream; charset=utf-8"
}serve({
AgUiEncoder: {
encode: (event, ctx) => {
if (event.type === "flow_start") {
return [{ data: { type: "RUN_STARTED", threadId: ctx.runId, runId: ctx.runId } }];
}
if (event.type === "token_delta") {
return [{ data: { type: "TEXT_MESSAGE_CONTENT", messageId: event.nodeName, delta: event.delta } }];
}
return [];
},
finish: (ctx) => [{ data: { type: "RUN_FINISHED", threadId: ctx.runId, runId: ctx.runId } }],
},
});encode receives the run event exactly as heddle's own protocol puts it on the wire, so
what you read is what a browser watching ?protocol=heddle reads. Returning [] is the
ordinary answer for an event your format does not render.
A frame is { event, data } for a named frame, or { data } alone for a nameless one,
which is what a protocol carrying its own type inside the payload wants. Both shapes exist
because the two protocols heddle already renders disagree about it, and both survive every
transport: SSE writes the name on an event: line, and the CLI writes the frame itself.
finish is called exactly once, on every path: a run that finished, one that failed, and
one whose caller hung up. It is where a terminal frame belongs. A client that never
receives one waits forever, and "the connection closed" is not something a protocol with a
terminal event should have to infer.
An encoder needs no capabilities and gets none. Event → frames asks heddle for no
tool, no model and no workspace, so it is the one kind that is complete with an empty
grant. It is also strictly one-directional: there is no verdict to return and no way to
affect the run being rendered.
If encode throws, the run stops. With your encoder selected your rendering is the
output, and a run whose answer nobody can read is not worth continuing to spend on. The
server ends the stream with an error frame; the CLI prints your message to stderr and exits
- Either way it is your error that surfaces, not the abort it caused.
What heddle emits
| Event | When | Carries |
|---|---|---|
flow_start | the run begins | — |
node_start | a node is entered | nodeName, nodeType, state, attempt |
node_complete | a node returns | nodeName, state (its own output), attempt |
node_error | a node throws | nodeName, error, attempt |
token_delta | a model produced a fragment | nodeName, delta |
tool_call | a tool is invoked | toolName, toolArgs, toolCallId |
tool_result | a tool returned or threw | toolName, toolResult or error, duration |
flow_complete | the run succeeded | state |
warning | something worth saying | message |
plugin_log | a plugin's log | level, message |
plugin:<Type>:<name> | a plugin's emitEvent | data |
EVENT_CONTRACT_VERSION says which shape of event you are reading. It is sent to every
plugin at init as events and reported on /v1/capabilities. Adding a field does not
move it; changing or removing one does. A mismatch is not a refusal, and an encoder reading
a later contract still renders every field it recognises.
Where the models disagree
Most of an encoder is a rename and an id. What takes thought is the handful of places where heddle's event model and the target protocol do not line up. The shipped AG-UI encoder is commented at each one; they are worth reading before writing your own.
Message boundaries are yours. heddle emits no event when a model's answer begins,
because it cannot honestly claim one is starting before knowing whether the node will
stream at all. An agent carrying a post transform streams nothing, and one calling
tools streams rounds whose text is discarded. What heddle does say is which node each
token_delta belongs to, and a node's deltas are contiguous. So open a message on the
first delta for a node and close it when that node finishes.
A message id is per node visit. attempt cannot serve: it is absent from
token_delta, and it resets when the flow advances, so a loop revisiting a streaming node
would reuse an id.
node_error is not a terminal event. Since middleware landed a node error is not
fatal: a retry may follow, and the same node can fail and then succeed. Rendering it as
your protocol's run-failed event would tell every client the run was over while it carried
on.
node_complete.state is the node's own output, not the run state. The runner merges
it in one line later. If your protocol's state event is a whole-state replacement,
accumulate the state yourself or each node will wipe the client's copy.
A failed tool has no result. heddle emits tool_result carrying an error and no
toolResult. If the target protocol's result frame has no error field, the message goes
in the content, or it is lost and reads as a tool that successfully returned nothing.
The complete worked example is in
examples/ag-ui:
a manifest, an encoder, a flow, one heddle run that renders it, and the curl that does
the same over HTTP.
A note on cost
An out-of-process encoder costs one round trip per event, and the event heddle emits most
is token_delta, one per fragment of every model answer. Nothing in the engine waits on
those round trips, so the cost is latency on the frame stream and CPU on the host process
rather than a slower flow, bounded by the run's own budget. An in-process encoder costs a
function call.