Skip to main content

Run the tool-calling flow

Start with Editor SDK

This guide extends a working Editor SDK integration. Editor AI SDK cannot open or control a document without Editor SDK. Complete the Editor SDK quickstart first.

The fastest complete example is the Editor AI SDK Playground. It opens a Thinkfree-owned sample document, connects Editor SDK to the editor iframe, and exposes the document tools to a small LLM loop.

Install the public npm packages Editor SDK and Editor AI SDK in your project directory. Editor AI SDK requires Editor SDK as a peer dependency.

npm install @thinkfree.dev/tfo-sdk @thinkfree.dev/tfo-ai-sdk

1. Open a document and connect Editor SDK

import { Office } from "@thinkfree.dev/tfo-sdk";

const iframe = document.querySelector("#office-frame");
const word = Office.word(iframe); // Office.cell for Spreadsheet, Office.show for Presentation
await word.whenReady(); // wait until the editor's SDK bridge answers

Office.word(iframe) derives the editor origin from iframe.src without a wildcard. Wait for the editor bridge with whenReady(). SDK connection cannot finish if the document failed to open; check the error inside the iframe first.

2. Get the Office tools

const tools = await word.getTools({ include: ["insert_text"] }); // choose names published by your editor
console.log(`${tools.length} Office tools available`);

getTools() reads the editor's tool catalog and returns executable tools - each with name, description, inputSchema (JSON Schema), and execute(args), which delegates to the editor. Keep the tool set small and task-specific with include. A shorter tool set improves selection quality and reduces the impact of an incorrect model decision.

Optionally add the module's own system prompt to your conversation:

const systemPrompt = await word.getSystemPrompt(); // "" when the module does not provide one

If your AI Agent provides registerTool and unregisterTool, use Editor AI SDK's bridgeToolsViaSdk(word, modelContext) to register Tools and call unregisterAll() on its returned handle to remove them. See the Editor AI SDK section of Quickstart for an integration that limits allowed Tools. The chat-ui example below selects Tools directly with getTools().

3. Send schemas, execute calls, return results

The chat-ui example below uses the Anthropic Messages content and stop_reason fields.

  1. Send the user request and allowed tool schemas through createMessage().
  2. Add the complete assistant response to the conversation history.
  3. Check each tool_use name, arguments, and duplicate call ID, and obtain user approval.
  4. Execute approved tools and return their tool_result blocks together in one user message, preserving each call ID.
  5. Stop when the model finishes calling tools. Run at most 8 steps.

tool.execute() returns { content, isError? }. Pass isError to the model and do not present a failed operation as successful. After a timeout, check whether the document changed before sending another request. Edits already applied are not automatically rolled back when the loop fails or reaches its limit. See Document tools and error handling for framework-specific schema mappings.

4. Implement createMessage: direct mode

Step 3 calls createMessage(), your function for sending the conversation to a model. In direct mode, the browser calls the provider API. API_KEY and MODEL are a key and an available model ID from your test account. This Anthropic example returns the normalized { content, stop_reason } shape used in step 3.

// Direct mode: the browser calls the provider API with your key - local testing only.
async function createMessage({ system, messages, tools }) {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": API_KEY, // visible to the page - never ship a key
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true", // local browser access
},
signal: AbortSignal.timeout(60000),
body: JSON.stringify({ model: MODEL, max_tokens: 8192, system, messages, tools }),
});
if (!res.ok) throw new Error(`Anthropic API error ${res.status}: ${await res.text()}`);
const data = await res.json();
return { content: data.content, stop_reason: data.stop_reason };
}

Direct mode needs no backend and is convenient on a personal workstation. The key is visible to the page and browser developer tools, so use it only for local tests. Never distribute a key in JavaScript or static files.

Relay server for production

Keep the provider key on a relay server in your backend and change createMessage() to call the relay server instead of the provider API. The request URL, the headers, and the structure of the request body change; the { content, stop_reason } response shape and the tool loop stay the same. See Relay server for the role of a relay server, how to run one, and a drop-in createMessage() for this sample, and Relay Server Protocol for the fields.

5. Disconnect

window.addEventListener("pagehide", () => word.disconnect());

disconnect() releases the SDK connection only; the editor and the document are not affected.

Expected result

The chat shows the selected tool and result, and the change appears in the document on the same page. If the model only returns prose, confirm that tool schemas were sent in the provider's required format and that the prompt clearly asks it to use tools.

chat-ui sample code

This example allows only appending text to a Word document. It does not expose selection replacement or editing at an arbitrary position. Limit the model-facing insert_text schema from getTools() to text, then validate the value and obtain user approval immediately before execution. Saving is not automatic; inspect the document and choose whether to save.

After installing the npm packages above, save the file below as index.html in your project directory and replace YOUR_OFFICE_DOCUMENT_OPEN_URL with a Word open URL. From the project directory, run python3 -m http.server 3000 --bind 127.0.0.1 and open http://localhost:3000. Enter a test API key and an available model ID in the page. Do not share the file with a key filled in.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Editor AI SDK local example</title>
<script type="importmap">
{"imports":{"@thinkfree.dev/tfo-sdk":"./node_modules/@thinkfree.dev/tfo-sdk/dist/index.js"}}
</script>
<style>
body { font-family: system-ui, sans-serif; }
#office-frame { width: 100%; height: 600px; border: 0; }
#log { white-space: pre-wrap; }
</style>
</head>
<body>
<label>Test API key <input id="key" type="password" autocomplete="off" /></label>
<label>Model ID <input id="model" autocomplete="off" /></label>
<form id="composer">
<input id="prompt" aria-label="Request" placeholder="Append a short greeting" required />
<button id="send" disabled>Send</button>
</form>
<pre id="log" aria-live="polite"></pre>
<iframe id="office-frame" title="Thinkfree Office document" src="YOUR_OFFICE_DOCUMENT_OPEN_URL"></iframe>
<script type="module">
import { Office } from "@thinkfree.dev/tfo-sdk";

const log = document.querySelector("#log");
const send = document.querySelector("#send");
const say = (text) => { log.textContent += text + "\n"; };
const word = Office.word(document.querySelector("#office-frame"));
let tool, schema, systemPrompt, busy = false;
try {
await word.whenReady();
[tool] = await word.getTools({ include: ["insert_text"] });
if (!tool || tool.inputSchema.properties?.text?.type !== "string") {
throw new Error("This Office version does not provide the expected insert_text tool.");
}
schema = {
type: "object",
properties: { text: { ...tool.inputSchema.properties.text, minLength: 1, maxLength: 2000 } },
required: ["text"], additionalProperties: false,
};
systemPrompt = await word.getSystemPrompt();
send.disabled = false;
say("Ready. Each approved call appends text to the document.");
} catch (error) {
say("Connection failed. Check the document open URL and Office/SDK versions. " + error.message);
}

async function createMessage({ system, messages, tools }) {
const API_KEY = document.querySelector("#key").value.trim();
const MODEL = document.querySelector("#model").value.trim();
if (!API_KEY || !MODEL) throw new Error("Enter a test API key and an available model ID.");
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": API_KEY,
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
},
signal: AbortSignal.timeout(60000),
body: JSON.stringify({ model: MODEL, max_tokens: 8192, system, messages, tools }),
});
if (!res.ok) throw new Error(`Model request failed (HTTP ${res.status}). Check the key, model and quota.`);
const data = await res.json();
if (!Array.isArray(data.content)) throw new Error("Invalid model response.");
return { content: data.content, stop_reason: data.stop_reason };
}

function validate(call) {
const args = call.input;
if (call.name !== tool.name || !args || typeof args !== "object" || Array.isArray(args)
|| Object.keys(args).length !== 1 || typeof args.text !== "string"
|| args.text.length < 1 || args.text.length > 2000) {
throw new Error("Only insert_text with 1–2000 characters of text is allowed.");
}
return args.text;
}

document.querySelector("#composer").addEventListener("submit", async (event) => {
event.preventDefault();
if (busy || !tool) return;
const prompt = document.querySelector("#prompt").value.trim();
if (!prompt) return;
busy = true;
send.disabled = true;
// A fresh conversation per submission avoids reusing an incomplete tool turn after an error.
const history = [{ role: "user", content: prompt }];
const seenCalls = new Set();
const definitions = [{ name: tool.name, description: "Append text to the document.", input_schema: schema }];
try {
for (let step = 0; step < 8; step++) {
const res = await createMessage({ system: systemPrompt, messages: history, tools: definitions });
history.push({ role: "assistant", content: res.content });
for (const block of res.content) if (block.type === "text") say(block.text);
if (res.stop_reason !== "tool_use") { say("Finished. Review the document before saving."); return; }
const calls = res.content.filter((block) => block.type === "tool_use");
if (!calls.length) throw new Error("The model requested tools without a tool call.");
const results = [];
for (const call of calls) {
if (typeof call.id !== "string" || !call.id || seenCalls.has(call.id)) {
throw new Error("Missing or repeated tool call ID.");
}
seenCalls.add(call.id);
const text = validate(call);
if (!window.confirm("Append this text?\n\n" + text)) {
say("Cancelled. No further tools will run."); return;
}
const result = await tool.execute({ text, insertAt: "end", replace: false });
say(result.isError ? "Tool failed. Inspect the document before retrying." : "Text append requested.");
results.push({ type: "tool_result", tool_use_id: call.id,
content: JSON.stringify(result), is_error: Boolean(result.isError) });
}
history.push({ role: "user", content: results });
}
say("Stopped at the 8-step limit. Review any edits already made before sending another request.");
} catch (error) {
say(error.message + " Existing edits are not rolled back. Check the document before retrying.");
} finally {
busy = false;
send.disabled = false;
}
});
window.addEventListener("pagehide", () => word.disconnect());
</script>
</body>
</html>

No model request is sent without an API key and model ID. For HTTP 401, check the key; for 429, check the provider's limits. If document connection fails, resolve the open error inside the iframe first. After cancelling approval, a tool error, or a timeout, inspect the document and request only the remaining work. A deployed service needs its own authentication, document authorization, and a relay server.