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.
| Rule | What it enforces |
|---|---|
| Graph shape | An object with a non-empty nodes list, at most 50 nodes |
| Identity | Every node has a unique, non-empty string id |
| One trigger | Zero or several triggers is a defect |
| Known kinds | An unknown type is rejected with the accepted vocabulary |
| Required payloads | prompt, expression, question where the kind cannot run without them |
| Edges | after is a list of strings naming real nodes, never itself, never a note |
| Acyclicity | A DFS over after reports the cycle path |
| Retries | An agent’s retries is a whole number in 0–3 |
| Deadlines | timeout_minutes is 1–4320; on_timeout is reject or escalate |
| Delivery | deliver is notification or channel; a channel target contains : |
| Conditions | out.<node>.<path> <op> <literal> only |
| Note tint | color 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": {}
} | Step | Behaviour |
|---|---|
agent | Uses the injected spawn seam; with no seam it parks at awaiting_agent with the prompt and rendered context |
condition | Evaluates the expression; false marks the step skipped and continues |
approval | Parks at awaiting_approval with question, options, context and waiting_since |
output | Collects {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
PluginLlmfacade, 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_agentsteps itself by performing the prompt and callingworkflow_design_respond. - No seam
- The run parks at
awaiting_agentand 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 isValueErrornaming 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
deliverieswithok: false.