How It Works
A complete picture of how the engine suspends a workflow, how the human response flows back in, and how the channel is completely decoupled from both of those steps.
The Three Layers
Every HIL channel implementation is built from three independent layers that never need to know about each other:
Layer 1 — What to show
Declared by the node type in its NodeFieldManifest.
The HilPolicy on each field descriptor states whether the field is
sent to HIL, how it's displayed, and whether the human can edit it.
This is channel-agnostic.
Layer 2 — How to show it
Implemented by the channel node executor. Reads the resolved HIL fields,
formats them for Slack / WhatsApp / email / etc., embeds the
ExecutionResId, sends the message, and returns the
"waiting" port.
Layer 3 — How to resume
Always the same: the channel's inbound webhook handler extracts the
ExecutionResId and the human's response, then calls
ContinueAsync. The engine does the rest.
End-to-End Flow
Node executor reads the NodeFieldManifest
The executor calls IHilLabelResolver.ResolveAllAsync to get all
fields where HilPolicy.SendToHil == true. Each resolved field
carries its label, description, current value, DisplayMode, and
InputMode.
DisplayMode values: ReadableContext (show value),
ReadableMasked (show as ****), Concealed (hide entirely).
InputMode values: Locked (read-only),
EditableOptional, RequiredFromHuman,
PrefilledEditable.
Executor formats the payload for the target channel
The resolved fields are translated into whatever format the channel needs —
Slack Block Kit blocks, a WhatsApp interactive message, an HTML email, etc.
Fields with InputMode = Locked become read-only display items.
Fields with editable input modes become input controls (buttons, text inputs).
ExecutionResId is embedded in the outgoing message
The ExecutionResId is the correlation key. It is embedded in the
outgoing message so it comes back with the human's response.
How it's embedded depends on the channel — see each channel's page for details.
// Available from the execution context
string resId = elementContext.ExecutionContext.ExecutionResId;
Node returns the "waiting" port — engine suspends
The executor returns a NodeExecutionResult with
OutputPortKey = "waiting". The engine serializes the full
ExecutionMemory (variables, node outputs, scope stack, loop state)
into a SuspendedExecutionData record and persists it to SQL.
The in-process thread is torn down. The server is free.
return NodeExecutionResult.Suspend("waiting");
Human receives and responds via the channel
The workflow is fully paused in the database. The human sees the message, reviews the fields, and responds — clicking a button, filling a form, replying to a message. This can happen seconds or days later. Server restarts don't matter.
Webhook handler receives the response
The channel fires an inbound event to your registered webhook endpoint.
Your handler extracts the ExecutionResId from the payload
and maps the human's response to a Dictionary<string, object>
keyed by field name.
ContinueAsync resumes the workflow
The handler calls IWorkflowContinuationOrchestrator.ContinueAsync.
The engine loads SuspendedExecutionData from SQL, restores
ExecutionMemory, merges the response data into the input bag,
and restarts the execution loop from the node's downstream connections.
await _continuationOrchestrator.ContinueAsync(
executionResId: resId,
responseData: humanResponse,
cancellationToken: ct);
SuspendedExecutionData — What Gets Saved
When the engine suspends, it persists everything needed for a full resume.
As a channel developer, the only field you need to care about is ExecutionResId.
| Field | What it contains |
|---|---|
ExecutionResId | The unique correlation key — embed this in every outgoing HIL message |
Memory | Full ExecutionMemory: all variables, node outputs, scope stack, loop stack |
SuspendedNodeKey | The ProcessElementKey of the node that triggered suspension |
ThreadVersionId | Which thread definition was running |
InputData | Original trigger data |
SuspendedAt | Timestamp for SLA tracking and expiry |
TenantId | Multi-tenant isolation |
ParentProcessContext | Reference to parent process for full hierarchy resume |
NodeFieldManifest — HilPolicy Reference
The HilPolicy on each NodeFieldDescriptor controls
the field's behaviour in HIL interactions:
| Property | Type | Meaning |
|---|---|---|
SendToHil | bool | If false, this field is invisible to HIL entirely — never include it in your message |
DisplayMode | HilDisplayMode | ReadableContext: show value · ReadableMasked: show as **** · Concealed: do not render |
InputMode | HilInputMode | Locked: display only · EditableOptional: can change · RequiredFromHuman: must provide · PrefilledEditable: pre-filled but editable |
Label | string? | Human-readable field label |
Description | string? | Additional context shown to the human |
LabelExpressionPolicy | ExpressionPolicy? | When non-null, label/description are dynamic expressions resolved at runtime via IHilLabelResolver |
Correlation Key Strategies
Different channels have different constraints on how much data can travel with an interaction event. Choose the strategy that fits:
| Strategy | How it works | Best for |
|---|---|---|
| Direct embed | The ExecutionResId (a GUID string) is placed directly in the interaction payload field (e.g. Slack action_id, Telegram callback_data) |
Slack, Telegram, Discord — payloads support long strings |
| Metadata embed | The ExecutionResId is stored in a dedicated metadata/private-metadata field separate from the visible interaction |
Slack (modal/view metadata), Teams (Adaptive Card data) |
| Correlation table | A short token (8–12 chars) is stored in a HilCorrelation table mapping token → ExecutionResId. The short token travels with the message. |
WhatsApp (256-char payload limit), SMS, any channel with tight payload limits |
| Signed URL | The ExecutionResId is embedded in a signed URL in the message. The human clicks the link, which hits a BizFirst endpoint. |
Email, any channel where you can include links |
| Session context | The active HIL execution for a given phone/user ID is tracked in a table. Inbound reply is correlated by sender identity. | SMS (reply-based), WhatsApp text replies |
ExecutionResId. Everything else is implementation detail.