Add human validation and SLAs
A human-task node suspends a run until a person decides, then resumes on whichever
output port that decision feeds. Give it a deadlineSeconds config and the wait races a
durable timer: past the deadline the run resumes on an escalation port instead, with no
human input needed. This recipe assumes you can already wire nodes port-to-port — see
Your first workflow and
Wire ports if not — and covers using an existing human-task node
from your plugins' catalog. To write a new one, see
Implement a node behavior.
1. Wire the node like any other
A human-task node is declared and wired exactly like a compute or sink node —
nothing in the YAML marks it as special:
nodes:
detect_condition: { type: clinical.condition.detect-from-egfr }
confirm_condition: { type: human.confirm-condition }
record_condition: { type: patient.append-condition }
connections:
- { from: detect_condition.proposal, to: confirm_condition.proposal }
- { from: confirm_condition.condition, to: record_condition.condition }
(Real example: content/clinical-demo/workflows/bio-result.workflow.yaml.) human.confirm-condition
outputs both decision (the raw verdict) and condition (present only if accepted) — wire
whichever ports your downstream nodes need; an unwired output is simply never consumed.
Two things to check before this validates:
- Ambient context. A
human-tasknode can require context keys (human.confirm-conditionrequirespatient) exactly like any other node — they must already be in the workflow'scontext:list, or static validation rejects the workflow with a missing-context error. - What the human sees. The title and payload shown to the validator come from the node's
proposebehavior, not from the workflow YAML — you pick which human-task node to use, a plugin developer defines what it asks. See Implement a node behavior §7.
2. Add an SLA deadline
Some human-task nodes declare a deadlineSeconds config field for this purpose — check the
node's config: entry in its plugin.yaml (or the generated nodes.ts) to see if it accepts
one; not every human-task node does (human.confirm-condition has no deadline at all).
Where it's declared, set it under the node's config: like any other config field:
acknowledge:
type: human.acknowledge-critical
config: { deadlineSeconds: 900 } # 15 minutes, then escalate
deadlineSeconds is a plain number of seconds. Under the hood the wait is a durable
timer — it survives process restarts and replays deterministically, it isn't a
setTimeout that resets on crash. See
Why durable execution for that
guarantee. Leave the config out (or the node has no such field) and the run waits for a
decision indefinitely — there is no default timeout.
3. Route the escalation branch separately
A human-task node with a deadline exposes (at least) two outputs: one fed when a human
decides in time, one fed when the deadline lapses first. Only one of the two is ever
populated per run — absence propagation cuts the other branch, so no guard node is needed
to keep them mutually exclusive. Wire each to its own downstream consumer:
# content/clinical-demo/workflows/critical-result-sla.workflow.yaml
nodes:
acknowledge: { type: human.acknowledge-critical, config: { deadlineSeconds: 900 } }
record_ack: { type: notify.ack-recorded }
escalate: { type: notify.escalate-on-call }
connections:
- { from: trigger.result, to: acknowledge.result }
- { from: acknowledge.acknowledged, to: record_ack.decision } # in time
- { from: acknowledge.escalation, to: escalate.result } # deadline expired
acknowledged and escalation are typed like any other ports (ValidationDecision and
Observation here) and connection compatibility is checked the same way. For the general
model behind "only one branch fires," see
Branching and absence.
4. Check it statically
Nothing about a human-task node is exempt from the usual static pass — required context,
existing ports, compatible connection types on both the timely and the escalation branch.
Run it before you rely on either branch:
pnpm plugin validate content/clinical-demo/workflows/critical-result-sla.workflow.yaml \
--plugin external-plugins/clinical --plugin external-plugins/demo
See Validate a workflow. The value a
human produces (decision, acknowledged, …) is itself checked again at runtime against
its declared port type, the same as any computed value — see
Two-level validation for how the static and
runtime passes split the work.
5. Test both branches without waiting on the clock
POST /api/simulate (and the editor's ▶ Simulate) never suspends: each human-task
node is decided according to a per-node scenario instead of a real wait, so you can force
the escalation branch without sitting through the deadline. Pass the decision as
"accepted", "rejected", or "timeout", keyed by node id:
curl -X POST localhost:4000/api/simulate -H 'content-type: application/json' -d '{
"file": "critical-result-sla.workflow.yaml",
"event": {"type":"CriticalResultReceived","encounterId":"sejour-7","analyte":"potassium","value":6.8,"unit":"mmol/L"},
"options": {"subject":{"kind":"patient","id":"patient-42"},"validations":{"acknowledge":"timeout"}}
}'
The report's validations array lists every proposal a human would have seen (title,
payload, deadline) plus the forced decision, and effects shows what escalate would have
written — nothing is actually applied. "timeout" is a simulation-only scenario value; it
is not a decision you can ever POST to a real run (see next step). Details in
Simulate a workflow.
6. Resolve real runs, or let the deadline fire
A real run suspends at the human-task node and shows up with status: "suspended" and a
pending request when polled (GET /api/runs/<id>) — see
Monitor executions. A human resolves it explicitly:
curl -X POST localhost:4000/api/runs/<id>/resolve -H 'content-type: application/json' \
-d '{"decision":"accepted","by":"Dr Martin"}'
decision is normalized server-side: anything other than the literal string "rejected"
counts as acceptance. Resolving a run with nothing pending (already decided, or already
timed out) returns 409. If a deadline is configured and nobody resolves it in time, the
timer fires on its own and the run proceeds on the escalation branch — no API call needed.
Full mechanics (idempotency, polling) in
Run and resolve.
Rather than a manual call, an instance can also resolve validations automatically from an
external signal — e.g. a FHIR Task flipped to completed in the EHR — by declaring an
event binding with resolve: instead of event:. That's an operator/plugin concern, not a
workflow-authoring one: see
Connect an event source and
Contribute an event source and binding.