Microsoft Teams
Microsoft Teams HIL Channel
Teams is the go-to enterprise HIL channel. Adaptive Cards support rich structured
layouts with both read-only and editable fields in a single card. The
Action.Submit data object carries the ExecutionResId and
all field values back in one interaction.
Why Teams Is Well-Suited for HIL
- Adaptive Cards — rich, flexible layout. A single card can show all context fields and contain editable inputs, giving the human a complete review interface without multiple messages.
- No messaging window restrictions — bots can message users proactively in Teams at any time (within the organization).
- Action.Submit data object — all input values plus any custom data you embed are returned together on submission.
- Approval workflows are a native Teams use case — users are familiar with Teams bot cards.
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 Field | Value |
|---|---|
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.