.docx; Vespper gives it three primitives —
read_document,
search_document, and
edit_document — and returns the file with every change recorded
as a Word tracked change.
Get set up
1
Create an API key
Create a key in the developer dashboard. Keys start
with
sk_live_ and the secret is shown only once, so copy it right away.export VESPPER_API_KEY="sk_live_YOUR_KEY"
2
Set your model key
Every example below drives an OpenAI model, so set that key too.
export OPENAI_API_KEY="sk-..."
3
Get a sample document
Create a project folder and drop a Word file named
sample.docx into it beside the script.mkdir vespper-quickstart && cd vespper-quickstart
How the document reaches the server
The tools take nobase64_data argument. Your client attaches the document to each call’s MCP
_meta object under com.vespper/document, and the tracked-change author under
com.vespper/author. The model therefore sees clean schemas and never spends context on
base64 — but it also means your code, not a turnkey chat client, has to inject it. Both
paths below do exactly that.
Use the tool descriptions that come from the MCP server. Don’t override them with your own —
they carry the anchoring, batching, and tracked-change rules the model needs to edit well.
Framework
Your framework runs the agent loop; you wire in the three tools and inject the document per call.- OpenAI Agents SDK
- Mastra
- Vercel AI SDK
pip install openai-agents "mcp<2"
main.py, then run python main.py.import asyncio
import base64
import os
from pathlib import Path
from agents import Agent, Runner, function_tool
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
async def main():
# 1. Load the document
docx_b64 = base64.b64encode(Path("sample.docx").read_bytes()).decode()
# 2. Connect to the Vespper MCP server
async with streamablehttp_client(
"https://mcp.vespper.com/mcp",
headers={"Authorization": f"Bearer {os.environ['VESPPER_API_KEY']}"},
) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# 3. Reuse the server's tool descriptions (they carry the editing rules)
desc = {t.name: t.description for t in (await session.list_tools()).tools}
# 4. Wrap the MCP tools as agent tools. We inject the document
# out-of-band in the MCP `_meta` field, so the model only sees
# clean args (pattern / edits) — never the base64.
@function_tool(description_override=desc["read_document"])
async def read_document():
return (
await session.call_tool(
"read_document",
{},
meta={"com.vespper/document": docx_b64},
)
).structuredContent
@function_tool(description_override=desc["search_document"])
async def search_document(pattern: str, start_at: int = 1):
return (
await session.call_tool(
"search_document",
{"pattern": pattern, "start_at": start_at},
meta={"com.vespper/document": docx_b64},
)
).structuredContent
@function_tool(
description_override=desc["edit_document"], strict_mode=False
)
async def edit_document(edits: list[dict[str, str]]):
nonlocal docx_b64
out = (
await session.call_tool(
"edit_document",
{"edits": edits},
meta={
"com.vespper/document": docx_b64,
"com.vespper/author": "Vespper Agent",
},
)
).structuredContent or {}
docx_b64 = out["base64"]
return out
# 5. Run the agent
await Runner.run(
Agent(
name="Example Agent",
instructions="You edit a Word document that is already loaded. Call read_document to load its HTML (or search_document to find text in a large document), then edit_document to apply the change as tracked edits.",
tools=[read_document, search_document, edit_document],
model="gpt-5.5",
),
"Add the word hello to the end of the document",
)
# 6. Save the redlined document
Path("sample-redlined.docx").write_bytes(base64.b64decode(docx_b64))
asyncio.run(main())
npm install @mastra/core @mastra/mcp zod dotenv
main.ts, then run npx tsx main.ts.import "dotenv/config";
import { readFileSync, writeFileSync } from "node:fs";
import { MCPClient } from "@mastra/mcp";
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
// 1. Load the document
let docx_b64 = readFileSync("sample.docx").toString("base64");
// 2. Connect to the Vespper MCP server
const mcp = new MCPClient({
servers: {
vespperDocx: {
url: new URL("https://mcp.vespper.com/mcp"),
requestInit: {
headers: { Authorization: `Bearer ${process.env.VESPPER_API_KEY}` },
},
},
},
});
const tools: any = await mcp.listTools();
// 3. Wrap the MCP tools as agent tools. We inject the document out-of-band in
// the MCP `_meta` field (the second arg to execute), so the model only ever
// sees clean args (pattern / edits) — never the base64. Tool
// descriptions/schemas come from the server. `docx_b64` is read at call time,
// so each call sends the latest (edited) document.
const read_document = createTool({
id: "read_document",
description: tools.vespperDocx_read_document.description,
execute: async () =>
tools.vespperDocx_read_document.execute(
{},
{ _meta: { "com.vespper/document": docx_b64 } }
),
});
const search_document = createTool({
id: "search_document",
description: tools.vespperDocx_search_document.description,
inputSchema: z.object({
pattern: z.string(),
start_at: z.number().optional(),
}),
execute: async ({ pattern, start_at }) =>
tools.vespperDocx_search_document.execute(
{ pattern, start_at },
{ _meta: { "com.vespper/document": docx_b64 } }
),
});
const edit_document = createTool({
id: "edit_document",
description: tools.vespperDocx_edit_document.description,
inputSchema: z.object({
edits: z.array(z.object({ old: z.string(), new: z.string() })),
}),
execute: async ({ edits }) => {
const out = await tools.vespperDocx_edit_document.execute(
{ edits },
{
_meta: {
"com.vespper/document": docx_b64,
"com.vespper/author": "Vespper Agent",
},
}
);
docx_b64 = out.base64;
return out;
},
});
// 4. Run the agent
const agent = new Agent({
id: "example-agent",
name: "Example Agent",
instructions:
"You edit a Word document that is already loaded. Call read_document to load its HTML (or search_document to find text in a large document), then edit_document to apply the change as tracked edits.",
model: "openai/gpt-5.5",
tools: { read_document, search_document, edit_document },
});
const result = await agent.generate("Add the word hello to the end of the document");
console.log(result.text);
// 5. Save the redlined document
writeFileSync("sample-redlined.docx", Buffer.from(docx_b64, "base64"));
console.log("Saved sample-redlined.docx");
await mcp.disconnect();
npm install ai @ai-sdk/openai @ai-sdk/mcp dotenv
main.ts, then run npx tsx main.ts.import "dotenv/config";
import { readFileSync, writeFileSync } from "node:fs";
import { createMCPClient } from "@ai-sdk/mcp";
import { generateText, stepCountIs } from "ai";
import { openai } from "@ai-sdk/openai";
// 1. The document lives in this holder so we can thread the latest version
// through every call and save it at the end.
const doc = { b64: readFileSync("sample.docx").toString("base64") };
// 2. Connect to the Vespper MCP server. The AI SDK doesn't expose per-call
// _meta on its tools, so we inject the document at the transport level: this
// fetch adds it to _meta on every tools/call (the model never sees the base64).
const mcp = await createMCPClient({
transport: {
type: "http",
url: "https://mcp.vespper.com/mcp",
headers: { Authorization: `Bearer ${process.env.VESPPER_API_KEY}` },
fetch: async (url, init) => {
if (init?.method === "POST" && typeof init.body === "string") {
const msg = JSON.parse(init.body);
if (msg.method === "tools/call") {
msg.params._meta = {
"com.vespper/document": doc.b64,
"com.vespper/author": "Vespper Agent",
};
init = { ...init, body: JSON.stringify(msg) };
}
}
return fetch(url, init);
},
},
});
// 3. The server's schema is already clean (read_document / search_document /
// edit_document take no base64), so use its tools directly. We only wrap
// edit_document to capture the patched document from its result — the SDK
// already parsed the response into structuredContent, so no scraping needed.
const tools = await mcp.tools();
const mcpEdit = tools.edit_document;
tools.edit_document = {
...mcpEdit,
execute: async (args, opts) => {
const out = await mcpEdit.execute(args, opts);
if (out?.structuredContent?.base64) doc.b64 = out.structuredContent.base64;
return out;
},
};
// 4. Run the agent
await generateText({
model: openai("gpt-5.5"),
system:
"You edit a Word document that is already loaded. Call read_document to load its HTML (or search_document to find text in a large document), then edit_document to apply the change as tracked edits.",
tools,
stopWhen: stepCountIs(5),
prompt: "Add the word hello to the end of the document",
});
// 5. Save the redlined document
writeFileSync("sample-redlined.docx", Buffer.from(doc.b64, "base64"));
await mcp.close();
Native
No framework — you own the agent loop. List the tools, describe them to the model keeping the server’s descriptions, then run tool calls until the edit lands. Because the server’s schemas are already clean, you can hand them to the model as-is.- Python
- TypeScript
pip install "mcp<2" openai
main.py, then run python main.py.import asyncio
import base64
import json
import os
from pathlib import Path
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from openai import OpenAI
async def main():
# 1. Load the document and create the OpenAI client
docx_b64 = base64.b64encode(Path("sample.docx").read_bytes()).decode()
client = OpenAI()
# 2. Connect to the Vespper MCP server
async with streamablehttp_client(
"https://mcp.vespper.com/mcp",
headers={"Authorization": f"Bearer {os.environ['VESPPER_API_KEY']}"},
) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# 3. Build the tool list straight from the server. Its schemas are
# already clean — read_document() / search_document(pattern) /
# edit_document(edits) — because the document rides in the MCP
# _meta field, not in the arguments. No hand-written schema needed.
listed = await session.list_tools()
tools = [
{
"type": "function",
"name": tool.name,
"description": tool.description,
"parameters": tool.inputSchema,
}
for tool in listed.tools
]
# 4. Let the model drive: it reads the document, then edits it
instructions = (
"You edit a Word document that is already loaded. Call read_document "
"to load its HTML (or search_document to find text in a large document), "
"then edit_document to apply the change as tracked edits."
)
conversation = [
{"role": "user", "content": "Add the word hello to the end of the document"}
]
while True:
response = client.responses.create(
model="gpt-5.5",
instructions=instructions,
tools=tools,
input=conversation,
)
conversation += response.output
tool_calls = [item for item in response.output if item.type == "function_call"]
if not tool_calls:
break
for call in tool_calls:
# 5. Run the tool the model asked for, injecting the document
# out-of-band in _meta (the model never sees the base64)
if call.name == "read_document":
result = await session.call_tool(
"read_document", {}, meta={"com.vespper/document": docx_b64}
)
output = result.structuredContent["html"]
elif call.name == "search_document":
# Forward the arguments as-is, so optional ones (start_at,
# for paging through a pattern with many matches) come along.
result = await session.call_tool(
"search_document",
json.loads(call.arguments),
meta={"com.vespper/document": docx_b64},
)
output = json.dumps(result.structuredContent)
else:
edits = json.loads(call.arguments)["edits"]
result = await session.call_tool(
"edit_document",
{"edits": edits},
meta={
"com.vespper/document": docx_b64,
"com.vespper/author": "Vespper Agent",
},
)
data = result.structuredContent
if "base64" in data:
docx_b64 = data["base64"]
output = data["message"]
# 6. Send the tool result back to the model
conversation.append(
{
"type": "function_call_output",
"call_id": call.call_id,
"output": output,
}
)
# 7. Save the redlined document
Path("sample-redlined.docx").write_bytes(base64.b64decode(docx_b64))
asyncio.run(main())
npm install @modelcontextprotocol/sdk openai
main.ts, then run npx tsx main.ts.import { readFileSync, writeFileSync } from "node:fs";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import OpenAI from "openai";
// 1. Load the document and create the OpenAI client
let docx_b64 = readFileSync("sample.docx").toString("base64");
const openai = new OpenAI();
// 2. Connect to the Vespper MCP server
const mcp = new Client({ name: "native-loop-example", version: "0.1.0" });
const transport = new StreamableHTTPClientTransport(
new URL("https://mcp.vespper.com/mcp"),
{ requestInit: { headers: { Authorization: `Bearer ${process.env.VESPPER_API_KEY}` } } }
);
await mcp.connect(transport);
// 3. Build the tool list straight from the server. Its schemas are already
// clean — read_document() / search_document(pattern) / edit_document(edits) —
// because the document rides in the MCP _meta field, not in the arguments.
const listed = await mcp.listTools();
const tools = listed.tools.map((tool) => ({
type: "function",
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
}));
// 4. Let the model drive: it reads the document, then edits it
const instructions =
"You edit a Word document that is already loaded. Call read_document to load its HTML (or search_document to find text in a large document), then edit_document to apply the change as tracked edits.";
const conversation: any[] = [
{ role: "user", content: "Add the word hello to the end of the document" },
];
while (true) {
const response = await openai.responses.create({
model: "gpt-5.5",
instructions,
tools,
input: conversation,
});
conversation.push(...response.output);
const toolCalls = response.output.filter((item) => item.type === "function_call");
if (toolCalls.length === 0) break;
for (const call of toolCalls) {
// 5. Run the tool the model asked for, injecting the document out-of-band
// in _meta (the model never sees the base64)
let output: string;
if (call.name === "read_document") {
const result = await mcp.callTool({
name: "read_document",
arguments: {},
_meta: { "com.vespper/document": docx_b64 },
});
output = (result.structuredContent as { html: string }).html;
} else if (call.name === "search_document") {
// Forward the arguments as-is, so optional ones (start_at, for paging
// through a pattern with many matches) come along.
const result = await mcp.callTool({
name: "search_document",
arguments: JSON.parse(call.arguments),
_meta: { "com.vespper/document": docx_b64 },
});
output = JSON.stringify(result.structuredContent);
} else {
const edits = JSON.parse(call.arguments).edits;
const result = await mcp.callTool({
name: "edit_document",
arguments: { edits },
_meta: {
"com.vespper/document": docx_b64,
"com.vespper/author": "Vespper Agent",
},
});
const data = result.structuredContent as { base64?: string; message: string };
if (data.base64) docx_b64 = data.base64;
output = data.message;
}
// 6. Send the tool result back to the model
conversation.push({
type: "function_call_output",
call_id: call.call_id,
output,
});
}
}
// 7. Save the redlined document
writeFileSync("sample-redlined.docx", Buffer.from(docx_b64, "base64"));
await mcp.close();
sample-redlined.docx. Open it in Word and the change is there as a
suggestion, attributed to the author you passed.
Editing rules
- Copy
oldanchors verbatim fromread_documentorsearch_document. Never hand-write them. - Prefer one complete block — a
<p>,<li>, heading, or row — per edit. - Write
class="..."in botholdandnewto keep an element’s existing classes. - Batch independent edits into one call; they reconcile in parallel. Never put two edits on the same block.
- A batch can apply partially.
countsays how many landed andfailed_editsnames the rest — the applied ones are already in the returned document, so resend only the listed indexes, never the whole batch. - Applied edits stay visible as tracked changes until a human accepts or rejects them in Word.
Next steps
Primitives
Full parameters and return shapes for all three tools.
MCP server
Endpoint, transport, and the
_meta document channel.