bilig

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:

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

When To Add More Tools

Add tools only after the agent has a repeated need for them:

The same rule holds: every mutating tool should return computed verification and enough context for the caller to explain what changed.