Building a Channel Node
The universal five-step pattern every HIL channel node follows, regardless of which messaging platform it targets.
What You Are Building
A HIL channel node is a node executor that:
- Reads the field manifest to know what to present to the human.
- Formats those fields for the target channel.
- Sends the message via the channel's API.
- Returns
"waiting"to suspend the workflow. - Has a corresponding webhook controller that resumes the workflow when the human responds.
The node executor and the webhook controller are two separate entry points but share the same
ExecutionResId as the correlation key between them.
Step 1 — Resolve the HIL Fields
In OnProcess, resolve the HIL payload using IHilLabelResolver:
// Inject IHilLabelResolver into your executor constructor
var hilFields = await _hilLabelResolver.ResolveAllAsync(
nodeExecutionContext,
cancellationToken);
// hilFields is IReadOnlyList<ResolvedHilField>
// Each entry has:
// .FieldKey — the config field name
// .Label — human-readable label (may be runtime-resolved)
// .Description — additional context
// .CurrentValue — the field's current value (object)
// .DisplayMode — ReadableContext | ReadableMasked | Concealed
// .InputMode — Locked | EditableOptional | RequiredFromHuman | PrefilledEditable
// .WasEvaluated — true if label was a dynamic expression
ResolveAllAsync filters out fields where HilPolicy.SendToHil == false.
You never need to check this yourself.
Step 2 — Apply Display and Input Rules
Before formatting for the channel, apply the policy rules to each field:
foreach (var field in hilFields)
{
switch (field.DisplayMode)
{
case HilDisplayMode.ReadableContext:
// Show the value as-is
break;
case HilDisplayMode.ReadableMasked:
// Render value as "••••••••" — never expose the actual value
break;
case HilDisplayMode.Concealed:
// Skip this field entirely — do not render it
continue;
}
bool isEditable = field.InputMode is
HilInputMode.EditableOptional or
HilInputMode.RequiredFromHuman or
HilInputMode.PrefilledEditable;
bool isRequired = field.InputMode == HilInputMode.RequiredFromHuman;
// Format accordingly for your channel
}
Step 3 — Embed the ExecutionResId
Get the ExecutionResId from the execution context and embed it in the
outgoing message payload. The exact mechanism depends on the channel.
string resId = nodeExecutionContext
.ElementExecutionContext
.ExecutionContext
.ExecutionResId;
| Channel | Where to embed |
|---|---|
| Slack | action_id on buttons, or private_metadata on a modal |
| Short token in a correlation table; token in button payload | |
| Facebook / Instagram | Button payload field (string, up to 1000 chars) |
| Telegram | callback_data on InlineKeyboardButton (up to 64 bytes) |
| Teams | data property of Adaptive Card Action.Submit |
| Query parameter in a signed resume URL | |
| SMS | Session table keyed by phone number |
| Discord | Button custom_id field (up to 100 chars) |
| LINE | data field of Postback action (up to 300 chars) |
Step 4 — Send and Suspend
Send the formatted message via the channel's API, then return the "waiting"
port key. Do this in your OnProcess implementation:
protected override async Task<NodeExecutionResult> OnProcess(
NodeExecutionContext context,
CancellationToken cancellationToken)
{
var hilFields = await _hilLabelResolver.ResolveAllAsync(context, cancellationToken);
var message = BuildChannelMessage(hilFields, resId); // your formatter
await _channelClient.SendMessageAsync(recipientId, message, cancellationToken);
// Return "waiting" — engine serializes memory and suspends
return NodeExecutionResult.Suspend("waiting");
}
"waiting" port the engine begins tearing down the
in-process execution. Do not schedule async work that continues after the return.
Send the message, confirm it was accepted, then suspend.
Step 5 — The Webhook Handler
This is a separate controller endpoint that the channel calls when the human responds. The handler must:
Validate the inbound request
Every channel has a signature verification mechanism. Always validate before processing.
// Example: verify HMAC signature from channel header
if (!_signatureValidator.Validate(request))
return Unauthorized();
Extract the ExecutionResId
Parse the payload using the strategy you chose (direct embed, correlation table, signed URL, etc.).
string resId = ExtractResId(payload); // your strategy
Map the human's response
Convert the channel-specific response format into a Dictionary<string, object>
keyed by field name. Use the same field keys that appear in the HIL manifest.
var responseData = new Dictionary<string, object>
{
["approved"] = payload.Action == "approve",
["reviewerNotes"] = payload.InputValues["notes"] ?? ""
};
Call ContinueAsync
Pass the ExecutionResId and the response data. The engine does everything else.
await _continuationOrchestrator.ContinueAsync(
executionResId: resId,
responseData: responseData,
cancellationToken: ct);
return Ok(); // acknowledge to the channel — important!
Acknowledge to the channel (critical)
Most channels require a quick HTTP 200 acknowledgment response within 3 seconds,
or they will retry. If ContinueAsync is slow, acknowledge immediately
and process the resume in a background task / queue.
// Fast-ack pattern for channels with 3-second timeout
_ = Task.Run(() => _continuationOrchestrator.ContinueAsync(resId, data, CancellationToken.None));
return Ok();
Channel Node Checklist
| Requirement | Notes |
|---|---|
Inject IHilLabelResolver | Do not build the field list manually |
Respect DisplayMode.Concealed | Never render concealed fields |
Respect DisplayMode.ReadableMasked | Never expose the raw value of masked fields |
Embed ExecutionResId | Cannot resume without it |
Return "waiting" port | Any other port skips suspension |
| Validate webhook signature | All channels provide this — use it |
| Acknowledge webhook within timeout | Typically 3 seconds — use fast-ack if needed |
| Map response to field key dict | Keys must match the node's config field names |
| Handle expiry gracefully | If ExecutionResId is not found, the suspension may have expired or already been resumed |
ExpressionPolicy.AtConfigLoad pointing to
@{secret:YourSecretKey}. They are resolved before your executor runs and
are never exposed to HIL fields.