heddle

Server

The same document over HTTP, with the run streamed back as it happens.

heddle-server runs the flows heddle run runs, over HTTP. There is no separate deployment format and no rewrite: the same document, pointed at by a different program.

heddle-server --port 4319 --tools-dir ./tools --flows-root ./flows

Routing is hand-rolled on node:http. The production dependency list is exactly one entry (@heddle-run/core), which is worth more here than a router's ergonomics, because this is a surface that executes what it is sent.

Routes

RoutePurpose
POST /v1/runsExecute a flow, buffered
POST /v1/runs?stream=trueExecute a flow, streamed as Server-Sent Events
POST /v1/validateParse, compile and validate without running
POST /v1/sessionsIssue a session id, so runs can be held in a conversation
GET /v1/sessions/:idThe transcript, and whether a run in it is unfinished
DELETE /v1/sessions/:idDelete a conversation and everything in it
GET /v1/capabilitiesWhat this server permits, so a client can adapt
GET /healthzLiveness; true while the event loop answers
GET /readyzReadiness; 503 the moment draining begins
GET /metricsPrometheus text exposition

There is no authentication and no rate limiting. Do not expose this to a network you do not control.

Running a flow

Provide exactly one flow selector:

FieldMeaning
flow (object)An Agent Spec flow as JSON
flow (string)Flow source text, YAML or JSON
flowPathPath relative to --flows-root

Plus inputs, a JSON object handed to the flow as its starting state. An optional format names the input format of a string flow or of the file behind flowPath; /v1/capabilities lists the names on offer under formats. Without it, a string body is read as YAML (which reads JSON too) and a path resolves by its extension.

curl -sX POST 'localhost:4319/v1/runs' \
  -H 'content-type: application/json' \
  -d '{"flowPath": "demo.yaml", "inputs": {"query": "hello"}}'
{ "flow": "demo", "state": { "query": "hello", "result": "..." } }

Compilation happens before the stream opens. A malformed flow comes back as a real 400, not a 200 followed by an error frame. Once SSE headers are out the status is fixed at 200.

It is a POST rather than a GET because EventSource only issues GETs and a flow document does not belong in a query string: it is large, and it would end up in access logs. Consume the stream with fetch and a ReadableStream.

The event stream

With ?stream=true each runner event is one SSE frame, the event type as the frame name.

event: flow_start
data: {"type":"flow_start"}

event: node_start
data: {"type":"node_start","nodeName":"agent","nodeType":"AgentNode","state":{...}}

event: token_delta
data: {"type":"token_delta","nodeName":"agent","delta":"The "}

event: flow_complete
data: {"type":"flow_complete","state":{"result":"..."}}

The frames are the engine's own event model. Only two fields need translating for JSON: state becomes a plain object, and an error becomes {name, message}.

One extra frame name, error, carries failures that occur after the stream has opened and therefore cannot be an HTTP status. It is the transport's error channel, not a second event model.

Concatenating a node's token_delta events does not reconstruct its output. An agent that calls tools streams every round it makes, including rounds whose text is discarded, and an agent carrying a post transform does not stream at all. A delta is a report; the output is in flow_complete.

Another wire format

?protocol=<name> renders the run in a format a plugin supplies, AG-UI for instance. A protocol nothing renders is a 400 listing what this server can. See Encoders.

Sessions

Off by default. --session-store file turns them on; --session-store <ComponentType> uses a store plugin instead. Without one, a request naming a session is refused rather than silently ignored.

A conversation starts with an id this server issues:

curl -sX POST localhost:4319/v1/sessions -d '{}'
# {"id":"2f1a…"}

Then each run names it, and the agent is given the turns before it:

{ "flowPath": "support.yaml", "inputs": { "query": "and the second order?" }, "session": "2f1a…" }

The run answers as usual, plus the session id. GET /v1/sessions/:id returns the turns, the same messages the model was shown, and whether a run in it is unfinished.

Sending _chat_history yourself alongside "session" is refused: the session is the conversation, and heddle would have to pick one of the two to discard.

Two things sessions cost

Ids are issued, never chosen. A run naming an id this server never minted is a 404, not a new conversation by that name. Ids are random for a reason:

A session id is a bearer capability. This server has no authentication, so whoever holds one can read and continue that conversation. Unguessability is not authorization, so terminate auth in front of this if conversations are worth protecting.

The server stops being stateless. Two replicas backed by the file store hold two different sets of conversations under the same ids, so a caller's second message can reach a pod that has never heard of them. That is what the pluggable store is for: a shared store is what makes more than one replica work.

Durable runs, and stopping for a human

"durable": true checkpoints the run at every node boundary. "resume": true picks up an unfinished one:

{ "flowPath": "support.yaml", "session": "2f1a…", "resume": true }

A middleware may also suspend a run to wait on a person. The run answers 202 rather than an error, because nothing failed:

{
  "session": "2f1a…",
  "status": "suspended",
  "suspended": {
    "by": "ApprovalGate",
    "seam": "toolCall",
    "node": "assistant",
    "ask": { "tool": "refund", "arguments": { "amount": 4200 } }
  }
}

Streamed runs get a suspended event before the stream closes. Continue it with an answer:

{ "flowPath": "support.yaml", "session": "2f1a…", "resume": true, "answer": { "approved": true } }

The answer reaches the run as the result of the call that was waiting. Nothing that already ran runs again. See Sessions and the approval-gate example.

Installed plugins

--plugin installs a plugin for the life of the process. It provides components every run can name, and unlike a plugin sent with a request, it may provide middleware.

heddle-server \
  --plugin ./policies/spend-limit.json \
  --plugin-config SpendLimit=@/etc/heddle/spend-limit.json

That asymmetry is the point. A node, a transform, a provider or an encoder is chosen by whoever wrote the flow or made the request; middleware is chosen by nobody, runs on every node of every flow, and takes its settings from the command line. So this is the only way to install a retry policy, an approval gate or a spend limit. See Middleware.

What installing buys, and what it costs:

  • One process per plugin serves the whole server. A session, a connection pool or a warm cache survives between runs, which is why it works this way. A plugin keeping per-run state in a module variable is keeping it wrong: concurrent runs share the process.
  • Everything loads before the port opens. An unreadable manifest, a missing entry point, a duplicate component type or a --plugin-config that fails its plugin's schema is a server that does not start. Loading is not running: a plugin's process spawns on its first call, which is what keeps /v1/validate free.
  • A plugin that dies is restarted on the next call. One process serves every run, so leaving it dead would turn one run's crash into every later run's. The calls in flight when it died still fail.
  • A shared plugin says which call it is acting for. runTool from an installed plugin must name the call it was made inside; there is no server-wide fallback, because the tools reachable from a call are the ones its own run brought. Its stderr goes to the server's log rather than into a caller's error, for the same reason.
  • A submitted plugin cannot take an installed name. A request declaring a component type an installed plugin already provides is refused rather than shadowing it.

Installed plugins hold whatever capabilities their manifest declares, including callModel. They are the operator's own code from the operator's own filesystem, trusted the way --tools-dir is trusted. A submitted plugin is not, and is granted less.

--discover-tools lets an installed plugin declaring discoverTools be started so heddle can ask what tools it has, an MCP proxy typically. It is never available to a submitted plugin, whichever way the flag is set: reading a manifest runs nothing, which is what makes /v1/validate free, and discovery spends exactly that.

Submitted code

By default a request may only select a flow. With --allow-request-code it may also send tool scripts, plugins, and files for the workspace:

FieldShape
tools{ name, source, interpreter? }
plugins{ name, manifest, source }
files{ path, content }

files is the one that is not code. Each entry is copied into every node's workspace before the run starts, read-only, at path relative to the workspace root, so a submitted tool can read content the request carried instead of content the operator installed. path is checked the way a --mount destination is: relative, no climbing, nothing under .heddle. A path that collides with something the operator mounted is refused rather than shadowing it. content is the caller's own bytes; there is no way to name a file on the server, which is not the caller's to read.

The three share one byte budget, because to the caller they are one thing: bytes this request asked the server to hold. files is additionally capped by count, since each one is copied per node rather than per run. Both limits, and the caps on tools and plugins, are in /v1/capabilities under limits, so a client can read them rather than discover them by being refused.

Every submitted plugin is loaded out of process, the only path a request has. An in-process plugin would be imported into the server, and a caller who can do that has the server's environment, filesystem and memory. Each gets its own process, an empty environment, and is killed when the run ends. (--plugin accepts a JavaScript module as well as a manifest, because that one is the operator's own code.)

Without the flag, a request carrying any of the three is refused with a 400 rather than having it ignored: a caller whose plugin was silently dropped would see an unknown-component-type failure with no way to learn why, and one whose files were dropped would see a tool find an empty directory.

What a submitted plugin may do is narrower than what heddle can serve. runTool, emitEvent and log are granted. callModel is withheld entirely when the server supplies a default credential: a plugin can make a thousand model calls inside one node while the flow shows one, and that is survivable when the credential is the caller's own and not when it is the operator's. A submitted middleware is refused: it would run on every node of every flow, including other callers'. A submitted encoder is permitted, because it renders one run for the caller who asked and cannot alter it.

--allow-request-code accepts computation its callers choose and makes outbound requests to hosts they name. Restrict egress and put something in front of it. See DEPLOYMENT.md.

Draining

/readyz goes 503 the moment a drain begins, while the listener stays open, so a pod leaves the load balancer's rotation with its open streams intact. Runs in flight are given until --drain-timeout to reach their own end before anything is closed.

Closing connections up front, which is the obvious implementation, terminates every live stream on the spot, so every rolling deploy drops runs mid-flight.

Flags

heddle-server --help lists them, and the package README documents every one with its default. A test refuses to let a flag exist without a row there.