heddle
Plugins

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-ui

On 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

  1. Either way it is your error that surfaces, not the abort it caused.

What heddle emits

EventWhenCarries
flow_startthe run begins
node_starta node is enterednodeName, nodeType, state, attempt
node_completea node returnsnodeName, state (its own output), attempt
node_errora node throwsnodeName, error, attempt
token_deltaa model produced a fragmentnodeName, delta
tool_calla tool is invokedtoolName, toolArgs, toolCallId
tool_resulta tool returned or threwtoolName, toolResult or error, duration
flow_completethe run succeededstate
warningsomething worth sayingmessage
plugin_loga plugin's loglevel, message
plugin:<Type>:<name>a plugin's emitEventdata

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.