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 ./flowsRouting 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
| Route | Purpose |
|---|---|
POST /v1/runs | Execute a flow, buffered |
POST /v1/runs?stream=true | Execute a flow, streamed as Server-Sent Events |
POST /v1/validate | Parse, compile and validate without running |
POST /v1/sessions | Issue a session id, so runs can be held in a conversation |
GET /v1/sessions/:id | The transcript, and whether a run in it is unfinished |
DELETE /v1/sessions/:id | Delete a conversation and everything in it |
GET /v1/capabilities | What this server permits, so a client can adapt |
GET /healthz | Liveness; true while the event loop answers |
GET /readyz | Readiness; 503 the moment draining begins |
GET /metrics | Prometheus 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:
| Field | Meaning |
|---|---|
flow (object) | An Agent Spec flow as JSON |
flow (string) | Flow source text, YAML or JSON |
flowPath | Path 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.jsonThat 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-configthat 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/validatefree. - 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.
runToolfrom 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:
| Field | Shape |
|---|---|
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.