The widget renders more than a reply. Each button below runs a real turn — the JSON beside it is exactly what the backend sent.
This page is static, so a stub in its source answers the widget's fetch with the frames shown. Nothing is faked in the UI: what you see is the widget's own rendering of those frames, which is the point. Open the bubble, then press a button.
A long turn says what it is doing. Each step goes
running → ok, in place.
{"type":"activity","name":"search","label":"Searching bookings","status":"running"}
{"type":"activity","name":"search","label":"Searching bookings","status":"ok"}
A question frame renders choices. The answer goes back as
the next turn, so the model does not guess. questionId
is required — without it the frame is ignored, and this demo
showed nothing at all until it was added.
{"type":"question","questionId":"q_period_1",
"prompt":"Which period should I compare against?",
"options":[{"id":"yoy","label":"Same month last year"},
{"id":"prev","label":"Previous month","description":"Shorter, noisier"}]}
The model proposes; the user applies. The card is the security boundary — everything the host would send is on it, and an unrecognised tool still renders its arguments rather than a blank card.
{"type":"tool_proposal","name":"publish_page",
"args":{"slug":"/autumn-offer","title":"Autumn offer","visibility":"public"}}
Emitted inline in the text as a directive, not as a frame.
[[chips:{"options":["Show me last week","Compare to last year","Export CSV"]}]]
The fields are declared by the turn, not hardcoded in the UI — so a new
question needs no frontend release. The widget only draws it when the
host passes onWidgetAction: a form with nowhere to submit is
worse than no form, so the directive stays inert text without one.
[[form:{"action":"book","title":"Request a callback","submit":"Send",
"fields":[{"name":"name","label":"Your name","type":"text"},
{"name":"email","label":"Email","type":"email"},
{"name":"when","label":"Best time","type":"select",
"options":["Morning","Afternoon","Evening"]},
{"name":"notes","label":"Anything else?","type":"textarea"}]}]]
Items carry a status and buttons. An action can require confirmation before the host is called.
The tiles read "Not made yet" where a picture
would be. That is the widget's own placeholder, not a bug: an item
references media by library id, never a URL, and a
static page has no library to resolve one against. Give a tile a
mediaId against a real backend and the placeholder becomes
the image, or the video with its poster.
[[ui:{"title":"Three drafts are ready","layout":"list",
"items":[{"id":"a","title":"Autumn offer","caption":"Ready to publish",
"status":"ready",
"actions":[{"name":"say","label":"Summarise",
"data":{"text":"Summarise the autumn offer"}},
{"name":"publish","label":"Publish","variant":"primary",
"confirm":"Publish this page?"}]}]}]]
A render_chart frame carries spec (the model's
arguments) and rows (the data). The widget never fetches
anything itself — your backend decides what the numbers are.
What draws them is a cascade of four, each falling through to the next:
the advanced pane, a host React renderer
(renderDataWidget), a host chart library
(renderChartFallback), and finally the built-in degrade.
This page passes none of them, so both buttons below
show the last tier — exactly what an embed with no chart library gets
out of the box: a stat tile for a kpi, a table for
everything else.
The built-in stat takes the last key of
the first row as the value and that key's name as the
caption. So {"period":"September","revenue":"€48,210"} reads
"revenue"; a row ending in a key called value reads "value",
which is the least useful word available. Name the last key after the
thing it measures.
{"type":"widget","spec":{"title":"Revenue this month","chartType":"kpi"},
"rows":[{"period":"September","revenue":"€48,210"}]}
{"type":"widget","spec":{"title":"Bookings by channel","chartType":"bar"},
"rows":[{"channel":"Direct","bookings":182},
{"channel":"Booking.com","bookings":147},
{"channel":"Expedia","bookings":83}]}
To draw a real chart, pass
renderChartFallback(host, spec, rows) and use any library
you like — it is handed a DOM node and the data, and returns an optional
disposer. If it throws, the widget falls through to the built-in rather
than losing the turn.
Pass getAdvancedUrl and the header gains a control that
opens your own app beside the chat, in an iframe. The assistant can then
highlight, scroll to, focus, fill and click things in it — with the user
watching each action.
That is a separate package,
sgiant-ai-agent-bridge:
mountAiAgent() runs inside your page and publishes
the controls you mark with data-ai-target; the widget drives
them from the parent. Selectors never cross the wire — only ids you
opted in.
// in the widget host
createAiChatWidget({ getAdvancedUrl: () => "/app" });
// in the page being driven
import { mountAiAgent } from "sgiant-ai-agent-bridge";
mountAiAgent(); // no-op unless framed
<button data-ai-target="save-profile">Save</button>
Not demonstrated live on this page: it needs a second app to drive, and a static example that framed itself would prove less than the four lines above already say. The bridge's own README shows both halves.
Register a tag and the widget hands you a DOM node and the parsed JSON. This is how you render your product's own cards without forking.
createAiChatWidget({
renderers: {
weather: (host, spec) => { host.textContent = `${spec.city}: ${spec.temp}°C`; },
},
});
// the model then emits:
[[weather:{"city":"Antalya","temp":24}]]