Serving conversational flow ads¶
A flow ad is a small, bounded, advertiser-authored conversation: message nodes, tappable choices, optional natural-language intent edges, and explicit ending actions. Instead of a card, your fetch returns the whole conversation graph — your surface (an AdKit-guided LLM, your bot runtime, or the web embed) executes it locally and reports engagement as it goes. No server round trips mid-flow, and no generated copy: every word the user sees was written by the advertiser.
Flow ads are opt-in. Integrations that never ask for them never receive one.
Requesting a flow¶
Add adFormat=Flow to your fetch (render format direqt, the default):
curl -s "https://ads.advertising.chat/v1/fetch/v2?key=YOUR_API_KEY&adUnit=YOUR_AD_UNIT&adFormat=Flow&subscriber=anon-123" \
-X POST -H "Content-Type: application/json" \
-d '{"context": "User just finished planning their morning routine and asked about coffee."}'
A 204 means no fill (a normal outcome — show nothing). A 200 returns
the flow payload.
The payload shape¶
{
"messageId": "direqt-…", // your fetchEventId for ALL flow events
"format": "direqt",
"payload": {
"specVersion": "1", // reject unknown majors (see version gate)
"entryNodeId": "hook",
"metadata": {
"advertiserName": "Acme Coffee",
"disclosureText": "Sponsored · Acme Coffee", // ALWAYS render
"teaser": "Better coffee in two taps", // optional
"language": "en"
},
"nodes": [
{ "id": "hook", "type": "message",
"text": "Craving better coffee? We roast to order.",
"next": "pick" },
{ "id": "pick", "type": "choice-set",
"prompt": "What do you brew at home?",
"choices": [
{ "id": "esp", "label": "Espresso", "target": "detail" },
{ "id": "no", "label": "No thanks", "target": "bye" }
],
"intents": [
{ "intent": "price_question",
"description": "The user asks about price, cost, or discounts.",
"examples": ["how much is it?"],
"target": "detail" }
] },
{ "id": "detail", "type": "action",
"text": "Fresh-roasted beans, shipped within 48 hours.",
"action": { "kind": "link_out",
"url": "https://…click-wrapped…",
"label": "Shop beans" } },
{ "id": "bye", "type": "action", "action": { "kind": "end" } }
],
"creativeId": "…"
},
"properties": {}
}
Three node types:
| Type | Renders as | Then |
|---|---|---|
message |
One message (text, optional imageUrl) |
follow next |
choice-set |
A prompt + up to 4 quick replies (choices); optional intents for free-text matching |
jump to the tapped choice's target |
action |
Optional closing text + the action affordance |
link_out / handoff / end are terminal; conversion may continue via next |
link_out and handoff URLs arrive already click-wrapped (shortened
redirect URLs that record the click before forwarding). Use them verbatim.
The full JSON Schema is published at
/schemas/flow-creative-1.json. Flows
are hard-bounded: at most 12 nodes, 4 choices per set, 6 user-visible
turns deep, 16 KB serialized.
Execution rules¶
The normative executor contract is the Ad Skill's Flow Execution addendum. The short version:
- Render advertiser copy verbatim — channel furniture (numbering, chip mapping) is fine; new sentences are not.
- Present
metadata.disclosureTextwith the first message, always. - Walk the graph from
entryNodeId; stop at terminal actions. - Free text on a choice-set: match against
intentsby theirdescription/examples; reportnlu_matchand jump. No match → usenoMatchTarget, else re-offer once, then exit withflow_abandon(no_match). - Report events as you go (below) — fire-and-forget.
- If the user disengages or objects, exit immediately
(
flow_abandon,user_exit). The conversation outranks the ad. - One flow at a time; never re-enter a finished flow.
Version gate: if specVersion has a major you don't know, don't run
the flow — report flow_abandon with reason unsupported_spec. Ignore
unknown fields added by minor versions.
Reporting engagement¶
All flow events go to the existing
event endpoint with version: 2 and
the fetch's messageId as fetchEventId:
curl -s "https://ads.advertising.chat/v1/event?key=YOUR_API_KEY" \
-X POST -H "Content-Type: application/json" \
-d '{
"version": 2,
"eventType": "choice_tap",
"eventData": { "nodeId": "pick", "choiceId": "esp" },
"fetchEventId": "direqt-…"
}'
201 on success; 422 if the eventType or eventData is invalid.
| eventType | When | eventData |
|---|---|---|
node_visit |
A node is presented | { "nodeId" } |
choice_tap |
User taps / numbers a choice | { "nodeId", "choiceId" } |
nlu_match |
Free text matched an intent | { "nodeId", "intent" } |
flow_complete |
A terminal action reached | { "terminalNodeId", "turns" } |
flow_abandon |
Executor exits early | { "lastNodeId", "turns", "reason" } — reason: user_exit, no_match, timeout, or unsupported_spec |
handoff_click |
Handoff affordance activated | { "nodeId", "handoffKind": "url" } |
Privacy rule: flow events never carry the user's words. nlu_match
reports the intent id, not the utterance; unexpected eventData keys are
dropped at ingress. Clicks on the wrapped URLs are tracked automatically
even if you send no events at all.
Webchat execution example¶
A minimal LLM-side loop (the AdKit MCP server + Ad Skill handle this for you on the MCP path):
fetch_ad(ad_format: "Flow") → payload
You: Sponsored · Acme Coffee
Craving better coffee? We roast to order.
[record_event node_visit {nodeId: "hook"}]
You: What do you brew at home? [Espresso] [No thanks]
[record_event node_visit {nodeId: "pick"}]
User: how much is a bag?
→ matches intent "price_question" (description: "asks about price…")
[record_event nlu_match {nodeId: "pick", intent: "price_question"}]
You: Fresh-roasted beans, shipped within 48 hours. [Shop beans]
[record_event node_visit {nodeId: "detail"}]
[record_event flow_complete {terminalNodeId: "detail", turns: 3}]
User: (taps Shop beans) → the wrapped URL records the click and redirects.
On SMS-class channels, choices degrade to numbered options
(1 Espresso 2 No thanks — reply with a number), smsText/smsPrompt
variants are preferred when present, and intent edges degrade to keyword
matching against their examples.