How Hermes Workflow works

Inside Hermes Workflow: one validator as the single rulebook, plan conversion, a resumable sequential runner, the host spawn seam, atomic run persistence and delivery receipts.

Validation is the rulebook

validate_designer_graph(graph) is one pure function that decides whether a graph is legal before anything is stored or executed. It does no I/O, imports nothing from the host, and never mutates its input — which is why the chat tools, the dashboard routes and the stores all share it instead of each enforcing its own copy.

A defect is {node, field, msg} and an empty list means the graph is valid. Only graph-shape failures short-circuit; everything else accumulates, so the canvas can show every problem at once instead of making you fix them one at a time.

RuleWhat it enforces
Graph shapeAn object with a non-empty nodes list, at most 50 nodes
IdentityEvery node has a unique, non-empty string id
One triggerZero or several triggers is a defect
Known kindsAn unknown type is rejected with the accepted vocabulary
Required payloadsprompt, expression, question where the kind cannot run without them
Edgesafter is a list of strings naming real nodes, never itself, never a note
AcyclicityA DFS over after reports the cycle path
RetriesAn agent’s retries is a whole number in 0–3
Deadlinestimeout_minutes is 1–4320; on_timeout is reject or escalate
Deliverydeliver is notification or channel; a channel target contains :
Conditionsout.<node>.<path> <op> <literal> only
Note tintcolor is yellow, blue, green, red or empty

_whole_number exists because a plain int() would accept 2.5 truncated and True cast to 1. A malformed count is rejected, not silently rounded.

The validator deliberately does not check runtime conditions: whether a referenced model exists, whether a delivery chat is reachable, or whether the host scheduler is available. Those are reported by their own modules when the run reaches them.

From graph to run plan

build_run_plan drops the trigger and the notes, then orders the remaining steps with a stable topological sort. It assumes a valid graph but still rejects duplicate or missing step ids and unknown kinds, because it is also reachable with hand-written JSON.

DesignStore.save re-validates before writing. The store is a second line of defence, not a second rulebook — a store that wrote arbitrary JSON would eventually be used by something that forgot to validate.

The sequential runner

The runner advances a JSON-serialisable state through the plan one step at a time, persists after every step, and pauses cleanly. cursor is the index of the *next* step, so resuming is just calling the runner again with the same state.

{
  "run_id": "d7b493cae249",
  "name": "hello-world",
  "cursor": 1,
  "status": "running",
  "outputs": {},
  "skipped": [],
  "pending": null,
  "error": null,
  "answers": {},
  "deliveries": [],
  "attempts": {}
}
Run state on disk. `skipped` is a list, not a set, so the whole state stays JSON-serialisable.
StepBehaviour
agentUses the injected spawn seam; with no seam it parks at awaiting_agent with the prompt and rendered context
conditionEvaluates the expression; false marks the step skipped and continues
approvalParks at awaiting_approval with question, options, context and waiting_since
outputCollects {parent id: output} under its own id and optionally delivers

Skip propagation is transitive: a step whose parent was skipped is skipped too, so a false condition prunes the whole downstream branch rather than only its immediate child.

An agent step may return structured or output. The runner checks key presence, not truthiness, so an agent that legitimately returns 0, false or {} does not have its payload replaced by an empty string.

Who performs an agent step

The plan does not call a model itself. It receives a spawn seam, and which seam you get depends on where the run started.

From chat
ctx.subagent_lifecycle — launch, wait, result — bound to the active parent session. This is the full child-agent path.
From the web dashboard
The host’s PluginLlm facade, which needs no parent session. That is the reason a browser canvas can run an agent node at all.
Cron
A fresh session answers awaiting_agent steps itself by performing the prompt and calling workflow_design_respond.
No seam
The run parks at awaiting_agent and reports what it needs. Nothing is guessed and no step is skipped.

Each launch carries the prompt, rendered context, an optional profile, a timeout, and a correlation id of the form workflow:<run_id>:<node_id>. Model overrides are trust-gated by plugins.entries.visual-workflow.llm.allow_model_override, and the plugin requests only a model, never a provider — the runtime derives the provider from the host catalog.

Persistence and receipts

Designs live at $HERMES_HOME/workflow-designs/<name>.json and runs at $HERMES_HOME/workflow-runs/<run_id>.json. Both are written to a temp file in the destination directory and then moved with os.replace, because the canvas polls run state while the runner saves after every step: a plain write would truncate first, and a concurrent read could see a partial file.

  • Design names are restricted to ^[a-z0-9][a-z0-9_.-]{0,63}$; run ids to ^[a-f0-9]{6,32}$. Both become filenames, so anything else would be a path-traversal bug.
  • A missing file is FileNotFoundError; an unreadable or corrupt file is ValueError naming the design or run.
  • RunStore.recent() reads every run on disk, so a run started by chat or by cron is visible and answerable in the canvas.
  • A failed delivery does not fail the run: it is recorded in deliveries with ok: false.