heddle

Sessions

Conversations that outlive the process. Turns on disk, durable runs, and stopping for a human.

A run of a flow starts, finishes, and is gone. A session is a conversation those runs belong to: heddle writes each exchange down, and the next run in the same session is given everything said before it. The conversation outlives the process. Close the terminal, come back tomorrow, continue where it stood.

Each heddle run inside a session is one turn: what the run was given, and what it came to. Sessions also hold the other thing worth writing down: a run that has not finished, either because the process died or because a middleware stopped it to wait for a person.

Starting one

--session with no id creates a session and prints its id; with an id, that session continues:

heddle run flow.json --tools-dir tools --session --input '{"query": "hello"}'
# Session: 2f1a…
heddle run flow.json --tools-dir tools --session 2f1a… --input '{"query": "and again?"}'

On the CLI you may also name one yourself, as in --session support-42, because the store is your own home directory, where a memorable name costs nothing. Ids are up to 128 characters of letters, digits, - and _. (The server refuses chosen ids; see below.)

The agent is handed the turns before it as its conversation history, spliced between its system prompt and the new message. That history travels under a reserved state key, _chat_history, which heddle writes and the flow never declares, and passing it yourself alongside --session is refused, because the session is the conversation and heddle would have to pick one of the two to throw away.

-i, --interactive opens the terminal chat UI. Alone, that conversation lasts as long as the terminal; with --session it is kept, and reopening with the same id starts on the conversation so far:

heddle run flow.json --tools-dir tools --session support-42 -i

Inspecting them

heddle sessions ls [--limit <n>]     # most recently used first
heddle sessions show <id> [--json]   # the transcript, or the stored record
heddle sessions rm <id>              # delete it and everything in it

A session with an unfinished run is listed as unfinished, and show says what it is waiting for. All the session commands, and heddle run, take --session-dir <path>; the default is $HEDDLE_SESSION_DIR, then ~/.heddle/sessions.

What is on disk

A session is a directory:

~/.heddle/sessions/<id>/
├── meta.json         # id, the flow it started with, createdAt
├── turns.jsonl       # one turn per line, appended
└── checkpoint.json   # present only while a run is unfinished

Three properties of the format are worth knowing, because they are what you rely on:

  • The transcript is append-only. A turn is one JSON line, appended when the run finishes. Nothing rewrites the file, so recording a long conversation never costs rewriting it, and a half-written line damages one turn rather than the conversation.
  • The checkpoint is a sibling, not a field. Writing and clearing a run's position never touches the transcript. Its presence is what "unfinished" means.
  • A failed turn is recorded too, with its error, but contributes nothing to the history a later run sees. A question the model already ignored once is not worth showing it again.

Durable runs

By default a session records finished turns and nothing in between: a process that dies mid-run loses that run. --durable writes the run's position down at every node boundary, so --resume picks it up at the node it had reached. Nothing before that node runs again:

heddle run flow.yaml --tools-dir tools --session support-42 --durable --input '{"query": "…"}'
# … the process dies …
heddle run flow.yaml --tools-dir tools --session support-42 --resume

It costs one store write per node, which is why it is asked for rather than implied by --session. The checkpoint holds the run's state as JSON, so everything a node produces has to survive JSON.stringify, and a run whose state cannot is refused at the node that produced it, not on resume hours later.

--durable, --resume and --answer are each an error without --session: they name a place to write to or read from, and without a session there is none.

A session with an unfinished run refuses a new turn. The previous run is either still going or waiting, and answering a new question first would append the two turns out of order. Resume it, or heddle sessions rm it.

Stopping for a human

The reason suspension exists: an agent about to do something consequential, whether refunding an order, deploying or deleting, and a person who has to say yes first, possibly hours later, from a different terminal.

A middleware, a policy installed by whoever runs heddle and consulted around every node and tool call, may suspend the run. The run stops, the question is written into the session's checkpoint, and the process can exit:

Stopped for a human: "ApprovalGate" is asking.
{
  "tool": "refund",
  "arguments": { "order": "991", "amount": 4200 },
  "question": "Approve refund?",
  "reply": { "approved": "true or false" }
}

Answer it with:
  heddle run <flow> --session support-42 --resume --answer '{"approved":true}'

--resume --answer '<json>' continues it. The answer reaches the run as the result of the call that was waiting: the model sees {"approved": true} where it expected the tool's output. Nothing that already ran runs again: not the model call for that round, not the tools already called in it. Calls the round never reached are made on the way back, because tool calls are not idempotent and a resume that repeated them would not be slow; it would be wrong.

--answer must be a JSON object, because a tool result is an object of named values, wrap a bare value as {"approved": true}. Its shape beyond that is the middleware's own: whatever the ask requested.

A suspension needs a session to be written into; a run without one is refused rather than stopped with no way back. It does not need --durable, because a suspension is always recorded, since a run stopped with nowhere to put the question would be unrecoverable.

A worked example

examples/approval-gate is the whole pattern in one out-of-process plugin: a middleware that suspends any call to a tool you name. From a checkout of the repository:

heddle run my-agent/flow.json --tools-dir my-agent/tools \
  --plugin examples/approval-gate/gate.json \
  --plugin-config ApprovalGate='{"tools":["refund"]}' \
  --session support-42 \
  --input '{"query": "refund order 991"}'

When the agent asks for refund, the run stops and prints the question above. Answer it from this terminal, another one, or an hour later:

heddle run my-agent/flow.json --tools-dir my-agent/tools \
  --plugin examples/approval-gate/gate.json \
  --plugin-config ApprovalGate='{"tools":["refund"]}' \
  --session support-42 --resume --answer '{"approved": true}'

The gate itself is a dozen lines; its toolCall.before hook returns { action: "suspend", ask: {…} } for the tools it gates and { action: "proceed" } for everything else. Middleware covers writing one.

Sessions over HTTP

heddle-server has the same machinery with two rules of its own. Sessions are off by default. Start it with --session-store file to turn them on, or --session-store <ComponentType> to use a store plugin such as examples/session-store, which is what makes more than one replica work. And ids are issued, never chosen: a session id is the only thing between a caller and somebody else's conversation, so a name anybody could guess is a name anybody could read.

RoutePurpose
POST /v1/sessionsIssue a session id
GET /v1/sessions/:idThe transcript, and whether a run in it is unfinished
DELETE /v1/sessions/:idDelete a conversation and everything in it

A conversation starts with an id the server mints, then each run names it:

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

curl -sX POST localhost:4319/v1/runs \
  -H 'content-type: application/json' \
  -d '{"flowPath": "support.yaml", "inputs": {"query": "hello"}, "session": "2f1a…"}'

"durable": true and "resume": true in the body are the flags' equivalents. A run a middleware suspended answers 202 rather than an error, because nothing failed, and carries the question:

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

and a second request with "resume": true and an "answer" object continues it. Streamed runs get a suspended event before the stream closes. See Server for what sessions cost a deployment: the bearer-capability warning is there, and it is worth reading before exposing any of this.

Where to go next