Portal Community

Why Teams Is Well-Suited for HIL

Correlation Strategy: Action.Submit Data

Adaptive Card Action.Submit accepts a data object whose properties are merged with input field values and returned in the activity payload. Embed the ExecutionResId in this data object:

{
  "type": "Action.Submit",
  "title": "Approve",
  "data": {
    "hilAction": "approve",
    "hilResId":  "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}

Building the Adaptive Card

private object BuildAdaptiveCard(
    IReadOnlyList<ResolvedHilField> fields,
    string resId)
{
    var body = new List<object>();

    // Title
    body.Add(new {
        type   = "TextBlock",
        text   = "Approval Required",
        weight = "Bolder",
        size   = "Medium"
    });
    body.Add(new { type = "Separator" });

    foreach (var field in fields)
    {
        if (field.DisplayMode == HilDisplayMode.Concealed) continue;

        string val = field.DisplayMode == HilDisplayMode.ReadableMasked
            ? "••••••••"
            : field.CurrentValue?.ToString() ?? "—";

        if (field.InputMode == HilInputMode.Locked ||
            field.DisplayMode == HilDisplayMode.ReadableMasked)
        {
            // Read-only fact set entry
            body.Add(new {
                type  = "FactSet",
                facts = new[] { new { title = field.Label, value = val } }
            });
        }
        else
        {
            // Editable text input
            body.Add(new {
                type        = "TextBlock",
                text        = $"{field.Label}{(field.InputMode == HilInputMode.RequiredFromHuman ? " *" : "")}",
                weight      = "Bolder",
                spacing     = "Medium"
            });
            body.Add(new {
                type        = "Input.Text",
                id          = field.FieldKey,
                value       = val,
                isRequired  = field.InputMode == HilInputMode.RequiredFromHuman,
                placeholder = field.Description ?? ""
            });
        }
    }

    var actions = new object[] {
        new {
            type  = "Action.Submit",
            title = "Approve ✅",
            style = "positive",
            data  = new { hilAction = "approve", hilResId = resId }
        },
        new {
            type  = "Action.Submit",
            title = "Reject ❌",
            style = "destructive",
            data  = new { hilAction = "reject", hilResId = resId }
        }
    };

    return new {
        type    = "AdaptiveCard",
        version = "1.5",
        body,
        actions
    };
}

Sending the Card via Bot Framework

// Using Bot Framework SDK (Microsoft.Bot.Builder)
var card           = BuildAdaptiveCard(hilFields, resId);
var adaptiveCard   = new Attachment
{
    ContentType = "application/vnd.microsoft.card.adaptive",
    Content     = card
};

var activity = MessageFactory.Attachment(adaptiveCard);

// TurnContext is passed from the bot's OnMessageActivityAsync, or use proactive messaging
await turnContext.SendActivityAsync(activity, cancellationToken);

return NodeExecutionResult.Suspend("waiting");

Proactive Messaging

For workflows that need to send a HIL card without being triggered by an inbound Teams message (i.e., the bot is not in an active turn), use Teams Proactive Messaging:

// Store the ConversationReference when the user first interacts with the bot
// Then use it to send a proactive message from outside a bot turn:
var botAdapter = _serviceProvider.GetRequiredService<IBotFrameworkHttpAdapter>();
await ((BotAdapter)botAdapter).ContinueConversationAsync(
    _settings.BotAppId,
    conversationReference,
    async (turnContext, ct) =>
    {
        await turnContext.SendActivityAsync(activity, ct);
    },
    cancellationToken);

Webhook Handler — Activity Endpoint

// The Bot Framework routes all activities to your bot class.
// Override OnEventActivityAsync or handle invoke activities for card submissions.

protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> ctx, CancellationToken ct)
{
    // Adaptive Card submit sends an invoke activity type, not a message.
    // Handle in OnInvokeActivityAsync or via Action.Submit → adaptiveCard/action invoke
}

// For Action.Submit:
protected override async Task<InvokeResponse> OnAdaptiveCardInvokeAsync(
    ITurnContext<IInvokeActivity> ctx,
    AdaptiveCardInvokeValue invokeValue,
    CancellationToken ct)
{
    var data   = invokeValue.Action.Data as JObject;
    var action = data?["hilAction"]?.ToString();
    var resId  = data?["hilResId"]?.ToString();

    if (resId == null) return new InvokeResponse { Status = 400 };

    // Collect all input values from the card
    var inputValues = invokeValue.Action.Data as IDictionary<string, object>;

    var responseData = new Dictionary<string, object>(inputValues)
    {
        ["approved"]    = action == "approve",
        ["respondedBy"] = ctx.Activity.From.Name
    };

    _ = Task.Run(() => _continuationOrchestrator.ContinueAsync(resId, responseData));

    return new InvokeResponse { Status = 200 };
}

Required Configuration

Config FieldValue
BotAppId@{secret:TeamsBotAppId}
BotAppPassword@{secret:TeamsBotAppPassword}
RecipientAadObjectId@{input:teamsUserAadId} — the user's Azure AD Object ID
TenantId@{env:TEAMS_TENANT_ID}
Adaptive Card versioning Use Adaptive Card schema version 1.5 for Input.Text with isRequired. Teams clients are regularly updated but always verify target client versions in your organization match the schema version used.