Beyond streaming text

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.

Live activity steps

A long turn says what it is doing. Each step goes runningok, in place.

{"type":"activity","name":"search","label":"Searching bookings","status":"running"}
{"type":"activity","name":"search","label":"Searching bookings","status":"ok"}

The assistant asks the human to decide

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"}]}

A confirm-gated write

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"}}

Suggestion chips

Emitted inline in the text as a directive, not as a frame.

[[chips:{"options":["Show me last week","Compare to last year","Export CSV"]}]]

A form the model designed

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"}]}]]

A card with its own actions

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?"}]}]}]]

Statistics — a chart frame, and what you actually get

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.

Advanced mode — the assistant driving your app

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.

Your own renderer — the open extension point

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}]]