Portal Community

What You Are Building

A HIL channel node is a node executor that:

  1. Reads the field manifest to know what to present to the human.
  2. Formats those fields for the target channel.
  3. Sends the message via the channel's API.
  4. Returns "waiting" to suspend the workflow.
  5. 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
Only SendToHil fields are returned 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;
ChannelWhere to embed
Slackaction_id on buttons, or private_metadata on a modal
WhatsAppShort token in a correlation table; token in button payload
Facebook / InstagramButton payload field (string, up to 1000 chars)
Telegramcallback_data on InlineKeyboardButton (up to 64 bytes)
Teamsdata property of Adaptive Card Action.Submit
EmailQuery parameter in a signed resume URL
SMSSession table keyed by phone number
DiscordButton custom_id field (up to 100 chars)
LINEdata 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");
}
Do everything before returning "waiting" Once you return the "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:

1

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();
2

Extract the ExecutionResId

Parse the payload using the strategy you chose (direct embed, correlation table, signed URL, etc.).

string resId = ExtractResId(payload); // your strategy
3

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"] ?? ""
};
4

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!
5

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

RequirementNotes
Inject IHilLabelResolverDo not build the field list manually
Respect DisplayMode.ConcealedNever render concealed fields
Respect DisplayMode.ReadableMaskedNever expose the raw value of masked fields
Embed ExecutionResIdCannot resume without it
Return "waiting" portAny other port skips suspension
Validate webhook signatureAll channels provide this — use it
Acknowledge webhook within timeoutTypically 3 seconds — use fast-ack if needed
Map response to field key dictKeys must match the node's config field names
Handle expiry gracefullyIf ExecutionResId is not found, the suspension may have expired or already been resumed
Settings class tip Store channel credentials (bot token, page token, webhook secret) as config fields on your node with ExpressionPolicy.AtConfigLoad pointing to @{secret:YourSecretKey}. They are resolved before your executor runs and are never exposed to HIL fields.