본문으로 건너뛰기

Tool calling 흐름 실행

Editor SDK로 시작

이 가이드는 동작 중인 Editor SDK 통합을 확장합니다. Editor AI SDK는 Editor SDK 없이는 문서를 열거나 제어할 수 없습니다. 먼저 Editor SDK 빠른 시작을 완료하십시오.

가장 빠른 완전한 예제는 Editor AI SDK Playground입니다. Thinkfree가 제공하는 샘플 문서를 열고, Editor SDK를 편집기 iframe에 연결하고, 문서 도구를 작은 LLM 루프에 노출합니다.

npm에 공개된 Editor SDKEditor AI SDK를 프로젝트 디렉터리에 설치합니다. Editor AI SDK는 Editor SDK를 필수 의존성으로 사용합니다.

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

1. 문서를 열고 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)iframe.src에서 편집기 origin을 유도하며 와일드카드는 사용하지 않습니다. whenReady()로 편집기 브리지가 응답할 때까지 기다립니다. 문서 열기에 실패한 상태에서는 SDK 연결도 완료되지 않으므로 iframe 안의 오류부터 확인합니다.

2. Office 도구 얻기

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

getTools()는 편집기의 도구 카탈로그를 읽어 실행 가능한 도구를 반환합니다 - 각 항목은 name, description, inputSchema(JSON Schema), 그리고 편집기에 위임하는 execute(args)를 가집니다. include로 도구 집합을 작고 작업에 맞게 유지하십시오. 도구가 적을수록 선택 품질이 높아지고 잘못된 모델 판단의 영향이 줄어듭니다.

선택적으로 모듈이 제공하는 시스템 프롬프트를 대화에 추가할 수 있습니다.

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

AI Agent가 registerToolunregisterTool을 제공한다면 Editor AI SDK의 bridgeToolsViaSdk(word, modelContext)로 Tool을 등록하고, 반환된 핸들의 unregisterAll()로 등록을 해제할 수 있습니다. 허용할 Tool을 제한하는 연결 예제는 Quickstart의 Editor AI SDK 절을 참고하세요. 아래 chat-ui 예제는 getTools()로 Tool을 직접 선택하는 방식을 사용합니다.

3. 스키마 전송, 호출 실행, 결과 반환

아래 chat-ui 예제는 Anthropic Messages 형식의 content, stop_reason을 사용합니다.

  1. 사용자 요청과 허용된 도구 스키마를 createMessage()로 보냅니다.
  2. assistant 응답 전체를 대화 이력에 추가합니다.
  3. tool_use의 이름·인자·중복 호출 ID를 검사하고 사용자 승인을 받습니다.
  4. 승인된 도구를 실행하고, 같은 호출 ID를 가진 tool_result를 모아 user 메시지 하나로 반환합니다.
  5. 모델이 도구 호출을 마치면 종료합니다. 최대 8단계까지만 실행합니다.

tool.execute(){ content, isError? } 형식의 결과를 반환합니다. isError를 모델에 전달하고 실패를 성공으로 표시하지 않습니다. 시간 초과 후에는 문서에 작업이 반영됐는지 확인한 뒤 다시 요청합니다. 이미 실행된 편집은 루프가 실패하거나 제한에 도달해도 자동으로 되돌아가지 않습니다. 프레임워크별 스키마 변환은 문서 도구 및 오류를 참고합니다.

4. createMessage 구현: direct 모드

3단계는 createMessage()를 호출합니다 - 대화를 모델에 보내는 여러분의 함수입니다. 루프를 가장 빨리 돌려 보는 방법은 direct 모드로, 브라우저가 공급자 API를 직접 호출합니다. API_KEYMODEL은 사용자의 테스트 계정에서 준비한 키와 사용 가능한 모델 ID입니다. 아래 예제는 Anthropic direct 호출이며, 3단계가 사용하는 정규화된 { content, stop_reason } 형태를 반환합니다.

// 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 모드는 백엔드가 필요 없어 개인 워크스테이션에서 편리합니다. 다만 키가 페이지와 브라우저 개발자 도구에 그대로 보이므로 로컬 테스트에만 사용하고, JavaScript나 정적 파일에 키를 담아 배포하지 마십시오.

프로덕션은 릴레이 서버로

공급자 키는 백엔드의 릴레이 서버에 두고, createMessage()가 공급자 API 대신 릴레이 서버를 호출하도록 바꾸십시오. 바뀌는 것은 요청 URL, 헤더, 요청 본문의 구조이며, 응답으로 받는 { content, stop_reason } 형태와 tool loop는 그대로입니다. 릴레이 서버의 역할과 실행 방법, 그리고 이 샘플에 맞춘 createMessage() 교체 함수는 릴레이 서버를, 요청/응답 필드는 릴레이 서버 요청/응답 스펙을 참고하십시오.

5. 연결 해제

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

disconnect()는 SDK 연결만 해제합니다. 편집기와 문서에는 영향이 없습니다.

예상 결과

채팅에 선택된 도구와 결과가 표시되고, 같은 페이지의 문서에 변경 사항이 나타납니다. 모델이 산문만 반환하면 도구 스키마가 공급자의 필수 형식으로 전송되었는지, 프롬프트가 도구 사용을 명확히 요청하는지 확인하십시오.

chat-ui 샘플 코드

이 예제는 Word 문서 끝에 텍스트를 추가하는 작업만 허용합니다. 선택 영역 교체와 임의 위치 편집은 노출하지 않습니다. 모델에 보낼 스키마를 getTools()insert_text 입력 중 text로 제한하고, 실행 직전에 값과 사용자 승인을 확인합니다. 저장은 자동 실행하지 않으므로 문서에서 결과를 검토한 뒤 저장합니다.

위 npm 설치를 완료한 프로젝트 디렉터리에 아래 파일을 index.html로 저장합니다. YOUR_OFFICE_DOCUMENT_OPEN_URL을 Word open URL로 바꿉니다. 프로젝트 디렉터리에서 python3 -m http.server 3000 --bind 127.0.0.1을 실행하고 http://localhost:3000을 엽니다. 화면에서 테스트 API 키와 계정에서 사용할 수 있는 모델 ID를 입력합니다. 이 파일을 키가 입력된 상태로 공유하지 않습니다.

<!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>

API 키 또는 모델 ID가 없으면 모델 요청을 보내지 않습니다. HTTP 401이면 키를, 429이면 제공자의 한도를 확인합니다. 문서 연결이 실패하면 iframe의 열기 오류를 먼저 해결합니다. 승인 취소·도구 오류·시간 초과 후에는 문서를 확인하고 필요한 작업만 다시 요청합니다. 실제 서비스에는 별도 인증·문서 권한 확인과 릴레이 서버가 필요합니다.