FormDesigner → MS Teams: Adaptive Card renderer and bot submit path (FEAT-551)¶
What it does¶
FormDesigner can export any FormSchema as an MS Teams Adaptive Card via the teams render
format. Unlike the default adaptive renderer, the teams renderer never sends the answers
itself — it returns JSON only. The terminal Submit action of the card carries a signed routing
envelope (_formdesigner); the MS Teams bot (ai-parrot-integrations) reads that envelope out
of the incoming Action.Submit activity, verifies it, and forwards the answers to the same
FormDesigner REST endpoint a web submitter would hit:
FormDesigner API MS Teams
───────────────── ────────
GET .../render/teams?with_meta=true ──► render the card in a message
user fills the card, taps Submit
Action.Submit activity ◄── Bot Framework delivers activity.value
verify envelope + POST .../data ──► MSTeamsAgentWrapper._handle_formdesigner_submit
200/422/403 JSON ◄──
send_card(build_reply_card(...)) ──► confirmation/error card back to the user
Rendering a form¶
GET {base_path}/{tenant}/forms/{form_uid}/render/teams
GET {base_path}/{tenant}/forms/{form_uid}/render/teams?with_meta=true
The teams format is only registered when a public base URL is configured — either via the
FORMDESIGNER_PUBLIC_URL environment variable, or by passing public_base_url= to
setup_form_api():
from parrot_formdesigner.api.routes import setup_form_api
setup_form_api(app, registry, public_base_url="https://forms.example.com")
Without a configured URL, "teams" is absent from supported_formats() and the render
endpoint answers 415 for it, exactly like any other unregistered format. ?with_meta=true
returns a JSON envelope of the render result — {"content", "content_type", "warnings",
"metadata"} — instead of the raw card body; the default (no query param) response is
unchanged for every other renderer.
The _formdesigner envelope¶
The terminal Submit action's data carries a TeamsSubmitEnvelope:
{
"_action": "submit",
"_formdesigner": {
"v": 1,
"wire": "legacy",
"form_uid": "5b1f5b3a-8f2e-4b1a-9b3a-1a2b3c4d5e6f",
"tenant": "navigator",
"form_version": "1.0",
"is_public": true,
"submit_url": "https://forms.example.com/api/v1/navigator/forms/5b1f.../data",
"form_url": "https://forms.example.com/navigator/forms/5b1f...",
"sig": "9f2c...a1b3"
}
}
sig is the HMAC-SHA256 hex digest of the envelope's canonical JSON (every field except sig,
keys sorted, compact separators), computed with TeamsSubmitEnvelope.sign() when the renderer
is configured with a signing_secret. sig is omitted (null) when no secret is configured.
Bot configuration¶
MSTeamsAgentConfig gains four fields for FormDesigner card submissions:
| Field | Env fallback | Meaning |
|---|---|---|
formdesigner_allowed_hosts |
{NAME}_FORMDESIGNER_ALLOWED_HOSTS (comma-separated) |
Host allowlist for submit_url. Empty/unset disables the whole branch. |
formdesigner_submit_token |
{NAME}_FORMDESIGNER_SUBMIT_TOKEN |
Bearer token sent to the FormDesigner API (needed for private forms). |
formdesigner_submit_secret |
{NAME}_FORMDESIGNER_SUBMIT_SECRET |
HMAC secret; when set, every envelope must carry a valid sig. |
formdesigner_submit_timeout |
— | Seconds before the forwarding POST gives up (default 15.0). |
formdesigner_api_base_path |
{NAME}_FORMDESIGNER_API_BASE_PATH |
Must match the base_path passed to setup_form_api() on the FormDesigner side (default /api/v1). A mismatch makes verify_envelope's path check reject every submission. |
{NAME} is the bot's configured name, upper-cased (same convention as the existing Jira OAuth
fields on MSTeamsAgentConfig).
Security model¶
verify_envelope() (parrot.integrations.msteams.formdesigner_submit) enforces, in this fixed
order:
- https only —
submit_urlmust use thehttpsscheme. - Host allowlist —
submit_url's host must exactly match (case-insensitively) an entry informdesigner_allowed_hosts. - Path/tenant/uid match —
submit_url's path must equal{api_base_path}/{tenant}/forms/{form_uid}/databuilt from the envelope's own fields (never trusted verbatim from the activity). - Signature — when
formdesigner_submit_secretis configured, the envelope'ssigmust verify against it.
post_submission() never follows redirects (allow_redirects=False), bounds the response body
size, and never raises into the bot adapter — HTTP/timeout errors come back as
SubmitOutcome(status=0, error=...). A formdesigner_submit_token (Bearer) is required for
private forms, since the bot's request carries no navigator-auth session.
Teams limitations¶
"Adaptive Cards within Teams don't provide support for file or image uploads." — Microsoft
Because of this, TeamsFormRenderer degrades every upload-type field (FILE, IMAGE,
IMAGE_DROPZONE, MULTI_UPLOAD) explicitly: an instruction text plus an Action.OpenUrl to
the served web form page, and a RenderWarning(renderer="teams") — never a silent
Input.Text. Teams cards also cap out at Adaptive Card schema 1.6, and Teams ignores most
StyleSchema presentation hints (colors, fonts) — only structural layout carries over.
Duplicate submissions¶
The wrapper keeps a best-effort, in-process TTL cache (RecentActivityCache) keyed on the Bot
Framework activity.id, so a retried delivery of the same Submit doesn't double-forward. This
is process-local — a server-side idempotency key on the FormDesigner API itself is a follow-up,
not covered by this feature.
Walkthrough¶
- Render the form:
GET .../forms/{form_uid}/render/teamsand post the returned Adaptive Card JSON to a Teams conversation (however your integration delivers proactive/interactive cards). - The user fills the card and taps Submit.
- Bot Framework delivers an
Action.Submitactivity;MSTeamsAgentWrapper._handle_card_submissiondetects the_formdesignerkey and routes to_handle_formdesigner_submit— never to dialogs, never to the slash-command router. - The envelope is verified, the answers are extracted (every key except
_actionand_formdesigner), and forwarded toPOST .../forms/{form_uid}/dataover the bot's sharedaiohttp.ClientSession. - The outcome (200/422/403/timeout/...) is mapped to a small Adaptive Card via
build_reply_card()and sent back to the user withsend_card().
Troubleshooting¶
| Symptom | Cause |
|---|---|
GET .../render/teams → 415 |
No FORMDESIGNER_PUBLIC_URL / public_base_url configured — "teams" was never registered. |
TeamsRenderConfigError (renderer raises / 400 from the render dispatcher) |
No tenant resolvable (tenant= kwarg and form.tenant both empty), or no public base URL when building the envelope. |
| Bot replies "Form submissions are not enabled for this bot." | formdesigner_allowed_hosts is empty/unset on the bot's MSTeamsAgentConfig. |
| Bot replies with a rejection message instead of forwarding | verify_envelope() failed — check scheme/host/path/signature, in that order. |
Submit route on a private form returns 403 |
The caller has no tenant membership; configure formdesigner_submit_token and enable it on the private form's route. |