WorkPaper Tool-Calling Recipe For Agents
This recipe shows how to wrap @bilig/workpaper WorkPaper operations as
agent-callable functions without binding the workflow to one agent SDK.
Use this pattern when an agent needs to inspect, edit, verify, and persist a formula-backed workbook from Node. Do not screen scrape a spreadsheet UI when the WorkPaper API is available. Screenshots are useful for final human review, but they hide formulas, typed addresses, recalculation state, and persistence contracts.
Start with the package README for the public API contract:
packages/workpaper/README.md.
If you are another coding agent and need the shortest decision path first, use
the headless WorkPaper agent handbook.
For a runnable external example, use
examples/headless-workpaper and run
npm run agent:tool-call. If your app uses the OpenAI Agents SDK, run
npm run agent:openai-agents-sdk and read the
OpenAI Agents SDK WorkPaper tool guide.
If your app calls OpenAI Responses directly, run
npm run agent:openai-responses and read the
OpenAI Responses WorkPaper tool-call guide.
For a smaller writeback-only proof, run
npm run agent:verify. For framework-shaped wrappers that do not pull Vercel
AI SDK or LangChain into this repository, run
npm run agent:framework-adapters. For a CrewAI interop shape, use the
CrewAI WorkPaper spreadsheet tool
recipe; it keeps the WorkPaper code in TypeScript and exposes a small JSON
contract to the agent workflow.
If you want the real AI SDK loop, run npm run agent:ai-sdk-generate-text.
That script calls generateText() and tool() from ai, using ai/test as a
deterministic provider so no API key is needed.
For the streaming path, run npm run agent:ai-sdk-stream-text. That script
calls streamText() from ai, streams tool-call chunks and final text, and
keeps the WorkPaper read/write verification in ordinary TypeScript.
If your app calls OpenAI directly, start with the OpenAI Agents SDK tool guide or the Responses API function-calling guide and keep the WorkPaper functions below as your application-side tool handlers. If this is the path you are trying, use the OpenAI Responses tool-call discussion to say what readback or streaming transcript shape would make the example more useful.
Tool Contract
Expose a small, boring tool surface first:
readSummary(range)returns computed values and serialized inputs for a summary range.setInputCell(sheetName, address, value)validates the target sheet and A1 address, writes one value, and returns before/after computed verification.serializeWorkbook()exports a persisted WorkPaper document only after the edit succeeds.
Keep each tool deterministic. Let the agent choose the next action, but make the tool result carry enough evidence for verification.
Complete Node Example
import { WorkPaper, exportWorkPaperDocument, serializeWorkPaperDocument, type WorkPaperCellAddress } from '@bilig/workpaper'
type CellInputValue = string | number | boolean | null
type SummaryReadback = {
currentMrr: number
nextMonthMrr: number
}
type SetInputCellArgs = {
sheetName: string
address: string
value: CellInputValue
}
const workbook = WorkPaper.buildFromSheets({
Assumptions: [
['Metric', 'Value'],
['Growth rate', 0.1],
],
Revenue: [
['Segment', 'Customers', 'ARPA', 'MRR'],
['Self serve', 200, 30, '=B2*C2'],
['Sales', 15, 300, '=B3*C3'],
],
Summary: [
['Metric', 'Value'],
['Current MRR', '=SUM(Revenue!D2:D3)'],
['Next month MRR', '=B2*(1+Assumptions!B2)'],
],
})
const summarySheet = requireSheet('Summary')
const currentMrrAddress = requireCellAddress('Summary', 'B2')
const nextMonthMrrAddress = requireCellAddress('Summary', 'B3')
const tools = {
readSummary(range: string = 'Summary!A1:B3') {
const parsedRange = workbook.simpleCellRangeFromString(range, summarySheet)
if (parsedRange === undefined) {
throw new Error(`invalid summary range: ${range}`)
}
return {
range,
values: workbook.getRangeValues(parsedRange),
serialized: workbook.getRangeSerialized(parsedRange),
}
},
setInputCell({ sheetName, address, value }: SetInputCellArgs) {
const target = requireCellAddress(sheetName, address)
const before = readComputedSummary()
workbook.setCellContents(target, value)
const after = readComputedSummary()
const serializedWorkbook = serializeWorkbook()
return {
editedCell: workbook.simpleCellAddressToString(target, {
includeSheetName: true,
}),
before,
after,
checks: {
currentMrrChanged: before.currentMrr !== after.currentMrr,
nextMonthMrrChanged: before.nextMonthMrr !== after.nextMonthMrr,
serializedBytes: Buffer.byteLength(serializedWorkbook, 'utf8'),
},
}
},
serializeWorkbook,
}
console.log(tools.readSummary())
console.log(
tools.setInputCell({
sheetName: 'Revenue',
address: 'B3',
value: 25,
}),
)
function requireSheet(sheetName: string): number {
const sheetId = workbook.getSheetId(sheetName)
if (sheetId === undefined) {
throw new Error(`unknown sheet: ${sheetName}`)
}
return sheetId
}
function requireCellAddress(sheetName: string, a1Address: string): WorkPaperCellAddress {
const sheetId = requireSheet(sheetName)
const parsed = workbook.simpleCellAddressFromString(a1Address, sheetId)
if (parsed === undefined) {
throw new Error(`invalid cell address: ${sheetName}!${a1Address}`)
}
if (parsed.sheet !== sheetId) {
throw new Error(`address ${a1Address} does not belong to ${sheetName}`)
}
return parsed
}
function readComputedSummary(): SummaryReadback {
return {
currentMrr: readNumber(currentMrrAddress, 'Current MRR'),
nextMonthMrr: readNumber(nextMonthMrrAddress, 'Next month MRR'),
}
}
function readNumber(address: WorkPaperCellAddress, label: string): number {
const value = workbook.getCellValue(address) as unknown
if (typeof value !== 'object' || value === null || !('value' in value) || typeof value.value !== 'number') {
throw new Error(`expected ${label} to be numeric, received ${JSON.stringify(value)}`)
}
return Math.round(value.value * 100) / 100
}
function serializeWorkbook(): string {
return serializeWorkPaperDocument(
exportWorkPaperDocument(workbook, {
includeConfig: true,
}),
)
}
The important check is not that the write call returned. It is that the computed summary changed as expected:
{
"editedCell": "Revenue!B3",
"before": {
"currentMrr": 10500,
"nextMonthMrr": 11550
},
"after": {
"currentMrr": 13500,
"nextMonthMrr": 14850
},
"checks": {
"currentMrrChanged": true,
"nextMonthMrrChanged": true,
"serializedBytes": 1155
}
}
serializedBytes will vary as the document schema evolves. Treat it as a
positive persistence check, not a stable snapshot value.
OpenAI Agents SDK Tool Wrapper
Use this path when your app builds agents with @openai/agents and wants the
WorkPaper functions attached to a real Agent as SDK function tools:
pnpm --dir examples/headless-workpaper run agent:openai-agents-sdk
The maintained example is
examples/headless-workpaper/openai-agents-sdk-tool-smoke.ts.
It creates tool() definitions for read_workpaper_summary and
set_workpaper_input_cell, attaches them to an Agent, and invokes them with
invokeFunctionTool() so the smoke remains provider-free.
The dedicated guide is
docs/openai-agents-sdk-workpaper-tool.md.
It links back to the official OpenAI Agents SDK tool docs:
https://openai.github.io/openai-agents-js/guides/tools/.
If your OpenAI Agents SDK app uses MCP servers instead of direct function tools, run the MCP smoke:
pnpm --dir examples/headless-workpaper run agent:openai-agents-sdk-mcp
It starts the Bilig WorkPaper stdio server with MCPServerStdio, converts the
MCP tools with getAllMcpTools(), invokes set_workpaper_input_cell, and
verifies computed readback plus restore.
For a zero-install hosted MCP check, run:
pnpm --dir examples/headless-workpaper run agent:openai-agents-sdk-hosted-mcp
It connects MCPServerStreamableHttp to https://bilig.proompteng.ai/mcp,
discovers the packaged WorkPaper tools, invokes
set_cell_contents_and_readback, and proves Summary!B3 changes
60000 -> 96000 with restored readback still 96000. The hosted endpoint is
stateless, so use the stdio MCP smoke for private writable files.
Expected proof:
{
"apiShape": "OpenAI Agents SDK Agent -> tool() -> invokeFunctionTool()",
"toolNames": ["read_workpaper_summary", "set_workpaper_input_cell"],
"writeResult": {
"editedCell": "Inputs!B3",
"before": { "expectedArr": 60000, "targetGap": -34000 },
"after": { "expectedArr": 96000, "targetGap": 5600 },
"checks": {
"formulasPersisted": true,
"restoredMatchesAfter": true,
"expectedArrChanged": true
}
}
}
OpenAI Responses API Tool Wrapper
OpenAI function tools should stay thin. The model chooses a tool call; your
Node process parses the arguments, runs the WorkPaper function, and sends the
structured result back as a function_call_output. Do not ask the model to
modify workbook JSON by hand.
The maintained repository script for this section is
examples/headless-workpaper/openai-responses-tool-wrapper.ts:
pnpm --dir examples/headless-workpaper run agent:openai-responses
The official Responses API function-calling flow preserves the model output,
executes every function_call, appends function_call_output items, and sends
that input back to the model. The WorkPaper-specific part is the dispatcher:
import OpenAI from 'openai'
type OpenAiToolResult = ReturnType<typeof tools.readSummary> | ReturnType<typeof tools.setInputCell>
type OpenAiWorkPaperCall = {
name: string
arguments: string
}
const openai = new OpenAI()
const openAiWorkPaperTools = [
{
type: 'function',
name: 'read_workpaper_summary',
description: 'Read computed WorkPaper summary values and serialized inputs for a small A1 range.',
parameters: {
type: 'object',
properties: {
range: {
type: 'string',
description: 'A small A1 range including the sheet name.',
default: 'Summary!A1:B3',
},
},
required: ['range'],
additionalProperties: false,
},
strict: true,
},
{
type: 'function',
name: 'set_workpaper_input_cell',
description: 'Set one validated WorkPaper input cell and return before/after formula readback.',
parameters: {
type: 'object',
properties: {
sheetName: {
type: 'string',
description: 'Target sheet name, for example Revenue.',
},
address: {
type: 'string',
description: 'A1 address inside the target sheet, for example B3.',
},
value: {
type: ['string', 'number', 'boolean', 'null'],
description: 'Literal input value. Use a separate tool for formulas.',
},
},
required: ['sheetName', 'address', 'value'],
additionalProperties: false,
},
strict: true,
},
] as const
const input: Array<Record<string, unknown>> = [
{
role: 'user',
content: 'Set Sales customers to 25, then tell me the current MRR and next month MRR.',
},
]
let response = await openai.responses.create({
model: process.env.OPENAI_MODEL ?? 'gpt-5',
tools: openAiWorkPaperTools,
input,
})
input.push(...response.output)
for (const item of response.output) {
if (item.type !== 'function_call') {
continue
}
const result = dispatchOpenAiWorkPaperCall({
name: item.name,
arguments: item.arguments,
})
input.push({
type: 'function_call_output',
call_id: item.call_id,
output: JSON.stringify(result),
})
}
response = await openai.responses.create({
model: process.env.OPENAI_MODEL ?? 'gpt-5',
instructions: 'Answer from WorkPaper tool output only. Mention the edited cell and computed readback.',
tools: openAiWorkPaperTools,
input,
})
console.log(response.output_text)
function dispatchOpenAiWorkPaperCall(call: OpenAiWorkPaperCall): OpenAiToolResult {
if (call.name === 'read_workpaper_summary') {
const args = JSON.parse(call.arguments) as { range?: string }
return tools.readSummary(args.range ?? 'Summary!A1:B3')
}
if (call.name === 'set_workpaper_input_cell') {
const args = JSON.parse(call.arguments) as SetInputCellArgs
const result = tools.setInputCell(args)
if (!result.checks.currentMrrChanged || !result.checks.nextMonthMrrChanged) {
throw new Error(`WorkPaper edit did not change the dependent summary: ${JSON.stringify(result.checks)}`)
}
return result
}
throw new Error(`unknown WorkPaper tool: ${call.name}`)
}
OpenAI Responses Streaming Transcript
The transcript below shows the same wrapper shape when your application streams
the Responses turn. The official streaming path emits
response.output_item.added when a function_call item starts,
response.function_call_arguments.delta while arguments stream, and
response.function_call_arguments.done when the application has the complete
JSON arguments. Keep the model output item with its call_id, then execute the
WorkPaper tools and append matching function_call_output items. The final
answer is grounded in the computed formula readback from WorkPaper.
[
{
"stream": "model",
"event": "response.output_item.added",
"response_id": "resp_workpaper_01",
"output_index": 0,
"item": {
"id": "fc_read_01",
"type": "function_call",
"call_id": "call_read_01",
"name": "read_workpaper_summary",
"arguments": ""
}
},
{
"stream": "model",
"event": "response.function_call_arguments.delta",
"response_id": "resp_workpaper_01",
"item_id": "fc_read_01",
"output_index": 0,
"delta": "{\"range\":\"Summary!A1:B3\""
},
{
"stream": "model",
"event": "response.function_call_arguments.done",
"response_id": "resp_workpaper_01",
"item_id": "fc_read_01",
"output_index": 0,
"name": "read_workpaper_summary",
"arguments": "{\"range\":\"Summary!A1:B3\"}"
},
{
"stream": "model",
"event": "response.output_item.done",
"response_id": "resp_workpaper_01",
"output_index": 0,
"item": {
"id": "fc_read_01",
"type": "function_call",
"call_id": "call_read_01",
"name": "read_workpaper_summary",
"arguments": "{\"range\":\"Summary!A1:B3\"}"
}
},
{
"stream": "model",
"event": "response.output_item.added",
"response_id": "resp_workpaper_01",
"output_index": 1,
"item": {
"id": "fc_write_01",
"type": "function_call",
"call_id": "call_write_01",
"name": "set_workpaper_input_cell",
"arguments": ""
}
},
{
"stream": "model",
"event": "response.function_call_arguments.done",
"response_id": "resp_workpaper_01",
"item_id": "fc_write_01",
"output_index": 1,
"name": "set_workpaper_input_cell",
"arguments": "{\"sheetName\":\"Revenue\",\"address\":\"B3\",\"value\":25}"
},
{
"stream": "model",
"event": "response.output_item.done",
"response_id": "resp_workpaper_01",
"output_index": 1,
"item": {
"id": "fc_write_01",
"type": "function_call",
"call_id": "call_write_01",
"name": "set_workpaper_input_cell",
"arguments": "{\"sheetName\":\"Revenue\",\"address\":\"B3\",\"value\":25}"
}
},
{
"stream": "app",
"type": "function_call_output",
"call_id": "call_read_01",
"output": "{\"range\":\"Summary!A1:B3\",\"values\":[[\"Metric\",\"Value\"],[\"Current MRR\",10500],[\"Next month MRR\",11550]],\"serialized\":[[\"Metric\",\"Value\"],[\"Current MRR\",\"=SUM(Revenue!D2:D3)\"],[\"Next month MRR\",\"=B2*(1+Assumptions!B2)\"]]}"
},
{
"stream": "app",
"type": "function_call_output",
"call_id": "call_write_01",
"output": "{\"editedCell\":\"Revenue!B3\",\"before\":{\"currentMrr\":10500,\"nextMonthMrr\":11550},\"after\":{\"currentMrr\":13500,\"nextMonthMrr\":14850},\"checks\":{\"currentMrrChanged\":true,\"nextMonthMrrChanged\":true,\"serializedBytes\":1155}}"
},
{
"stream": "model",
"type": "message",
"content": "Edited Revenue!B3. Current MRR moved from 10500 to 13500, and next month MRR moved from 11550 to 14850."
}
]
Use this as a transcript shape, not as a reason to add the OpenAI SDK to the
example package. The important handoff is that each function_call_output
returns structured WorkPaper data, especially editedCell, before, after,
and checks, so the model’s final message cites calculated cells that your
application verified.
The object returned to OpenAI should be the same object you would log in a local
smoke test: editedCell, before, after, and checks. That makes the final
assistant message explain the workbook change from computed readback instead of
from a guess.
Vercel AI SDK Tool Wrapper
Vercel AI SDK users can expose the same WorkPaper operations through an
AI-SDK-shaped tools object. This repository does not need the AI SDK as a
dependency; the snippet is for applications that already use ai and want a
familiar tool() wrapper:
import { tool } from 'ai'
import { z } from 'zod'
type WorkPaperToolValue = string | number | boolean | null
export const workPaperTools = {
readWorkPaperSummary: tool({
description: 'Read computed WorkPaper summary values and serialized inputs for a small range.',
inputSchema: z.object({
range: z.string().default('Summary!A1:B3').describe('A small A1 range, including the sheet name.'),
}),
execute: async ({ range = 'Summary!A1:B3' }: { range?: string }) => tools.readSummary(range),
}),
setWorkPaperInputCell: tool({
description: 'Set one validated WorkPaper input cell and return before/after formula readback.',
inputSchema: z.object({
sheetName: z.string().describe('Target sheet name, for example Revenue.'),
address: z.string().describe('A1 cell address inside the target sheet.'),
value: z
.union([z.string(), z.number(), z.boolean(), z.null()])
.describe('Literal cell value. Use a separate formula tool for formulas.'),
}),
execute: async ({ sheetName, address, value }: { sheetName: string; address: string; value: WorkPaperToolValue }) => {
const result = tools.setInputCell({ sheetName, address, value })
if (!result.checks.currentMrrChanged || !result.checks.nextMonthMrrChanged) {
throw new Error(`WorkPaper edit did not change the dependent summary: ${JSON.stringify(result.checks)}`)
}
return result
},
}),
}
Pass workPaperTools to generateText() or streamText() from your AI SDK
application. Keep the model-facing result structured: the mutating tool should
return editedCell, before, after, and checks so the next model step can
explain exactly what changed. Persist the serialized workbook only after these
computed readback checks pass.
If the application needs an audit trail, persist the AI SDK step payloads in
onStepFinish. Record step.toolCalls and step.toolResults there, then keep
the WorkPaper result structured enough to show Inputs!B3, before
expectedArr 60000, after expectedArr 96000, and
restoredMatchesAfter: true:
const transcript: unknown[] = []
await generateText({
model,
tools: workPaperTools,
stopWhen: stepCountIs(2),
prompt: 'Read Summary!A1:B5 and set Inputs!B3 to 0.4.',
onStepFinish(step) {
transcript.push({
stepNumber: step.stepNumber,
toolCalls: step.toolCalls,
toolResults: step.toolResults,
})
},
})
The detailed onStepFinish transcript shape is in
docs/vercel-ai-sdk-langchain-spreadsheet-tool.md,
next to the checked generateText() and streamText() smokes.
For a dependency-free runnable version of this shape, use
examples/headless-workpaper/agent-framework-adapters.ts:
pnpm --dir examples/headless-workpaper run agent:framework-adapters
For the actual AI SDK generateText() loop, use
examples/headless-workpaper/ai-sdk-generate-text-tool-smoke.ts:
pnpm --dir examples/headless-workpaper run agent:ai-sdk-generate-text
For the actual AI SDK streamText() loop, use
examples/headless-workpaper/ai-sdk-stream-text-tool-smoke.ts:
pnpm --dir examples/headless-workpaper run agent:ai-sdk-stream-text
LangChain Tool Wrapper
LangChain users can wrap the same SDK-neutral WorkPaper functions without adding
a LangChain dependency to this repository. In an app that already uses
LangChain, define thin tools around the tools object from the example above:
import { tool } from 'langchain'
import * as z from 'zod'
type WorkPaperToolValue = string | number | boolean | null
const readWorkPaperSummary = tool(({ range = 'Summary!A1:B3' }: { range?: string }) => tools.readSummary(range), {
name: 'read_workpaper_summary',
description: 'Read computed WorkPaper summary values and serialized inputs for a small range.',
schema: z.object({
range: z.string().default('Summary!A1:B3').describe('A small A1 range, including the sheet name.'),
}),
})
const setWorkPaperInputCell = tool(
async ({ sheetName, address, value }: { sheetName: string; address: string; value: WorkPaperToolValue }) => {
const result = tools.setInputCell({ sheetName, address, value })
if (!result.checks.currentMrrChanged || !result.checks.nextMonthMrrChanged) {
throw new Error(`WorkPaper edit did not change the dependent summary: ${JSON.stringify(result.checks)}`)
}
return result
},
{
name: 'set_workpaper_input_cell',
description: 'Set one validated WorkPaper input cell and return before/after formula readback.',
schema: z.object({
sheetName: z.string().describe('Target sheet name, for example Revenue.'),
address: z.string().describe('A1 cell address inside the target sheet.'),
value: z
.union([z.string(), z.number(), z.boolean(), z.null()])
.describe('Literal cell value. Use a separate formula tool for formulas.'),
}),
},
)
export const workPaperTools = [readWorkPaperSummary, setWorkPaperInputCell]
Return structured objects, not prose. LangChain will pass the returned object
back to the model as tool output, so keep the WorkPaper result explicit:
editedCell, before, after, and checks. In a durable app, write the
serialized workbook to external storage only after these computed readback
checks pass.
Agent Guardrails
- Validate sheet names with
getSheetId()before parsing a target address. - Parse user-facing addresses through
simpleCellAddressFromString()orsimpleCellRangeFromString()instead of building{ row, col }objects from ad hoc string splits. - Return computed values after every write; do not ask the agent to infer success from a rendered grid.
- Serialize only after a successful write and verification readback.
- Keep tool results small. Return the range, changed cell, before/after values, and persistence check; do not dump the whole workbook unless the agent asks for it.
- Use public
@bilig/workpaperexports and WorkPaper methods only. Do not import from internalsrc/,dist/, or monorepo package internals in an external agent workflow.
When To Add More Tools
Add tools only after the agent has a repeated need for them:
readRange(range)for broader model inspectionsetFormula(sheetName, address, formula)when formulas are first-class agent outputsvalidateFormula(address)when the workflow needs structured diagnosticspersistAndRestore()when the workflow must prove round-trip safety before committing output
The same rule holds: every mutating tool should return computed verification and enough context for the caller to explain what changed.