Building a Loop Component That Keeps Incident Status Live Everywhere
Back a Microsoft 365 Loop component with an Adaptive Card and Universal Actions so an incident status block stays live and editable across chat, email, and Loop pages.
- #microsoft-teams
- #ai
- #loop
- #adaptive-cards
- #collaboration
The pitch for Loop components is irresistible to anyone who’s run an incident: one living block of status that follows your team across a Teams chat, an Outlook email, and a Loop page, always showing the current state, editable in place, no copy-paste drift. Paste it once, and wherever it travels it’s the same live thing. The catch is that “living” is entirely a function of how you build it. Pick the wrong card primitive and you get a frozen snapshot that lies the moment anything changes. This post is about building a card-based Loop component that actually stays live — and stays safe when two responders edit it from two different surfaces at once.
Build on Universal Actions, not Action.Submit
A card-based Loop component is an Adaptive Card, but it has to use the Universal Action Model — Action.Execute plus the refresh mechanism — to behave correctly across Loop-enabled hosts. This is the single most important decision. Action.Submit is bound to the Bot Framework message flow and won’t give you the host-agnostic, refresh-on-open behavior that Loop’s whole premise depends on. Loop components render in Outlook and the Loop app, not just Teams, and Universal Actions is the design built for exactly that cross-host world.
The refresh block is what makes the component live:
{
"type": "AdaptiveCard",
"version": "1.4",
"refresh": {
"action": {
"type": "Action.Execute",
"verb": "refreshIncident",
"data": { "incidentId": "INC-0142" }
},
"userIds": ["<aad-object-id>"]
},
"body": [
{ "type": "TextBlock", "text": "INC-0142 — api-gateway latency", "weight": "Bolder" },
{ "type": "TextBlock", "text": "Severity: SEV2 · Commander: @bob", "wrap": true }
]
}
When a viewer opens the surface, the refresh action fires, your handler reads the current incident state from the source of truth, and returns an up-to-date card. That’s the difference between a Loop component that shows live status and one stuck displaying whatever was true when it was pasted three hours ago.
Inline edits write back to the source of truth
The component is more than a viewer — responders edit it. Severity changes, a new commander takes over, the status note updates. Each editable field is wired through Action.Execute to your backend, which writes to the canonical incident store and returns a refreshed card:
async function onExecute(context) {
const { verb, data } = context.activity.value.action;
if (verb === "setSeverity") {
await incidents.update(data.incidentId, { severity: data.severity }, data.etag);
return refreshedCard(await incidents.get(data.incidentId));
}
if (verb === "refreshIncident") {
return refreshedCard(await incidents.get(data.incidentId));
}
}
Read-only fields stay as plain TextBlocks with no action, so viewers see live data but can’t change what they shouldn’t. The split between editable and read-only is deliberate and lives in how you render the card, not in hope.
Two surfaces, one field: concurrency is not optional
Here’s the scenario that will happen: the component lives in both a Teams chat and a Loop page, and two responders change the severity at nearly the same time from the two surfaces. Without concurrency control, the second write silently clobbers the first, and now your incident record disagrees with what one of them believes they set.
The fix is optimistic concurrency — an ETag or version field carried in the card and checked on every write:
async function update(incidentId, patch, etag) {
const current = await store.get(incidentId);
if (current.etag !== etag) {
throw new ConflictError("incident changed since you loaded it");
}
return store.put(incidentId, { ...current, ...patch, etag: newEtag() });
}
When the ETag doesn’t match, you don’t silently overwrite — you return a refreshed card showing the current state and let the responder re-apply their change against reality. A silent clobber during an incident is the kind of bug that makes people stop trusting the tool entirely, so make the conflict visible and recoverable.
Authorization: read-only viewers can paste, but not change severity
Anyone who can see the component can paste it elsewhere, and that’s fine — sharing live status is the point. What’s not fine is letting any of those viewers change the incident’s severity or commander. The card can’t enforce that on its own, so the edit handlers resolve the acting user via SSO and check their authorization before writing:
if (verb === "setSeverity") {
const user = await getSsoUser(context);
if (!canEditIncident(user, data.incidentId)) {
return refreshedCard(await incidents.get(data.incidentId), {
banner: "You have read-only access to this incident",
});
}
// ...authorized: proceed with the ETag-checked write
}
Read-only viewers still get the live, refreshing card — they just can’t mutate it. That’s the right balance for a status block that travels widely.
Close the incident cleanly
When the incident resolves, the component should render a clear closed state and stop accepting edits, rather than letting someone reopen or alter a resolved incident days later from a stale paste. A refresh that returns a read-only “Resolved” card once the incident closes handles this automatically — the next time anyone opens the surface, they see it’s done.
Drafting with AI, verifying the unsafe parts
I draft the card and handlers with an AI assistant, then verify the two places where a plausible-but-wrong answer is genuinely dangerous — the concurrency check and the authorization gate:
Prompt: “Build a card-based Microsoft 365 Loop component for live incident status using Adaptive Card Universal Actions and the refresh model. Make severity and commander editable via Action.Execute with ETag-based optimistic concurrency and per-user authorization via SSO; keep the incident id and status note read-only; render a closed state when the incident resolves.”
Output (excerpt): A card with a
refreshblock andAction.Executeedits, an update handler that checks the ETag and returns a conflict-resolving refreshed card on mismatch, an SSO authorization gate before each write, and a read-only resolved-state card.
The model gets the Universal Action structure and refresh wiring right. What I verify by hand is that every write checks the ETag before mutating and that the authorization gate runs before the write — because a Loop component that lets a read-only viewer clobber another responder’s severity change is worse than no component at all.
The payoff
Build on Universal Actions and the refresh model so it’s live everywhere, write edits back to the source of truth with ETag-checked concurrency, gate mutations behind per-user authorization, and close the incident state cleanly. Do that and you get the Loop component the pitch promised: one living status block that’s safe to hand to a responder under pressure. For the related Loop and Universal Action patterns, the Microsoft Teams category has the surrounding guides, and the prompts library includes a Loop-component prompt that bakes in the concurrency and authorization handling so you start from a safe collaborative design rather than retrofitting one after the first silent clobber.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.