Use the Thinkfree chat UI with your own loop
The chat UI is served by the Office server that opens the document. Your page loads it in an iframe and drives it
through createChatUiBridge() from @thinkfree.dev/tfo-ai-sdk/chat-ui. Document tools still come from the
Office toolkit. Complete the quickstart first.
The Thinkfree chat UI shows progress chips, notification cards, choice prompts, edit-plan approval, and attachments. With the chat UI bridge you keep that UI and bring only two things: your tool loop and your model connection. The bridge exposes the eight chat UI tools to the model, renders the model's output in the UI, and reports user actions (choices, approvals, stop) back to your loop.
| Piece | Provided by |
|---|---|
| Chat UI (React, served by the Office server) | Thinkfree |
| Document tools, prompt rules, receipts | Office toolkit (@thinkfree.dev/tfo-sdk) |
| Chat UI tools, prompt rules, turn state, handshake | Chat UI bridge (@thinkfree.dev/tfo-ai-sdk/chat-ui) |
| Tool loop, model connection, relay | Your application |
1. Load the chat UI in external mode
Point an iframe at the chat UI page of the Office server with mode=external. The page then renders the UI only and
talks to your window instead of starting its own agent.
<iframe id="chat-frame" title="Thinkfree chat UI"
src="https://office.example.com/webword/commonFrame/js/addIn/tfoAiAssistant/index.html?mode=external&brainOrigin=https%3A%2F%2Fapp.example.com&module=word&lang=en"></iframe>
| Query parameter | Required | Meaning |
|---|---|---|
mode=external | yes | Render the UI only; the agent lives outside this iframe |
brainOrigin | yes | Exact origin of the page that creates the bridge (your page). * is rejected |
brainWindow=opener | no | Send to window.opener instead of window.parent when you open the UI with window.open() |
module | no | word, cell, or show - selects the welcome screen and quick actions |
lang | no | UI language, for example ko_KR or en |
2. Create the bridge
import { createOfficeToolkit } from "@thinkfree.dev/tfo-sdk";
import { createChatUiBridge } from "@thinkfree.dev/tfo-ai-sdk/chat-ui";
const toolkit = await createOfficeToolkit(word, { provider: "anthropic", preset: "core" });
const chatFrame = document.querySelector("#chat-frame");
const chat = createChatUiBridge({
peerWindow: chatFrame.contentWindow,
peerOrigin: "https://office.example.com", // exact origin of the chat UI, never "*"
provider: "anthropic", // same wire shape as toolkit.tools
language: { code: "en", name: "English" }, // language named in the prompt rules
listTools: () => toolkit.catalog, // answers the "/tools" command locally
onUserInput: runTurn, // your loop - see step 3
onAbort: () => console.log("user pressed stop"),
onSettingsChange: (settings, ctx) => { /* ctx.systemPrompt changed; use it on the next turn */ },
});
| Handle member | Use it for |
|---|---|
chat.tools | The eight chat UI tools in the provider wire shape. Send them together with toolkit.tools |
chat.systemPrompt | Rules for the chat UI tools (edit-accept or auto-proceed mode, developer mode). Append it to toolkit.systemPrompt |
chat.handles(name) | true when a tool call belongs to the chat UI; route it to chat.dispatch, everything else to toolkit.dispatch |
chat.dispatch(name, args) | Runs a chat UI tool and resolves with a receipt. Interactive cards resolve when the user answers |
chat.notify(text) | Renders the model's final text as a card. Skipped when the turn already reported through a card |
chat.settings | Current assistant settings: mode (edit-accept or auto-proceed) and developerMode |
chat.destroy() | Removes the listener and closes pending cards |
3. Run your loop on each user input
onUserInput receives the text and attachments the user sent and a turn context. Return the model's final text and
the bridge renders it; throw and the bridge shows an error card. turn.signal aborts when the user presses stop.
async function runTurn({ text, attachments }, turn) {
const system = `${toolkit.systemPrompt}\n\n${chat.systemPrompt}`;
const tools = [...toolkit.tools, ...chat.tools];
const history = [{ role: "user", content: text }];
for (let step = 0; step < 12; step++) {
if (turn.signal.aborted) return "";
const res = await createMessage({ system, messages: history, tools }); // see the quickstart
history.push({ role: "assistant", content: res.content });
if (res.stop_reason !== "tool_use") {
return res.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
}
const results = [];
for (const call of res.content.filter((b) => b.type === "tool_use")) {
const receipt = chat.handles(call.name)
? await chat.dispatch(call.name, call.input)
: await toolkit.dispatch(call.name, call.input);
results.push({ type: "tool_result", tool_use_id: call.id,
content: JSON.stringify(receipt), is_error: !receipt.success });
}
history.push({ role: "user", content: results });
}
return "";
}
Both dispatch functions return receipts, so the model reads one result shape for document tools and chat UI tools.
When the model calls show_request_input or show_edit_plan_list, chat.dispatch waits until the user chooses, accepts,
or closes the card, and the receipt text tells the model what happened. In edit-accept mode (the default) the prompt
rules tell the model to show an edit plan and wait for approval before it calls a document tool; your loop does not need
to add an approval step for that path.
4. Handshake and lifecycle
- Handshake. The bridge sends
tfo-ai:hellowhen created, and the UI answerstfo-ai:readywith its protocol version; the UI also sendsreadywhen it loads. Either side may come up first. Messages the bridge sends beforereadyare queued. If the versions differ, the UI shows an error card and the bridge stops;onProtocolMismatchfires. - Turn state. The bridge marks the turn as running when
onUserInputstarts and as finished when it settles, so the UI shows the stop button and queues further input meanwhile. - Stop. The user's stop aborts
turn.signal, callsonAbort, and closes waiting cards so that pendingchat.dispatchcalls resolve. A result or error that arrives after the stop is dropped. - Settings. When the user changes the assistant settings in the UI,
onSettingsChange(settings, { systemPrompt, notice })fires. Use the newsystemPromptfrom the next turn;noticeis a short message you may add to the conversation so the model notices the change. - Cleanup. Call
chat.destroy()when you remove the iframe.
Protocol reference (v1)
Every message is window.postMessage({ type, payload }, origin) with an exact origin on both sides.
| Message | Direction | Payload |
|---|---|---|
tfo-ai:ready | UI → bridge | { protocolVersion: 1, capabilities?: string[] } |
tfo-ai:hello | bridge → UI | { protocolVersion: 1 } |
tfo-ai:user-input | UI → bridge | { text, attachments?: [{ fileName, mimeType, size, base64 }] } |
tfo-ai:user-action | UI → bridge | { componentId, action: "select" | "select-custom" | "accept-all" | "close", value? } |
tfo-ai:set-assistant-settings | UI → bridge | { settings: { mode, developerMode } } |
tfo-ai:abort-turn, tfo-ai:clear-conversation | UI → bridge | none |
tfo-ai:component-add / -update / -remove | bridge → UI | { id, kind, data } / { id, data } / { id } |
tfo-ai:turn-state | bridge → UI | { inProgress } |
tfo-ai:trigger-input | bridge → UI | { text } - inject an utterance as if the user typed it |
Component kinds are progress-status, notification, request-input, edit-plan-list, and tool-call-info; the
chat UI tools (show_progress_status, update_progress_status, show_notification, show_request_input,
show_edit_plan_list, update_edit_plan_list, show_tool_call_info, remove_component) produce them. You do not
need to send component messages yourself; the bridge does it when the model calls a chat UI tool.
Limits in this release
- Attachments are passed to
onUserInputas base64 and are not uploaded anywhere by the bridge. Text and image attachments are practical; Office binary attachments (such as.pptx) are not bridged in external mode. - The chat UI is served by the Office server and must be loaded from there; hosting it on your own domain is not supported.
- The prompt rules are written in Korean with the UI language named inside; the model answers in the user's language.