Treat function calls from a model as proposals, not granted permissions. The verified sequence I recommend: the model proposes a JSON-shaped tool call; your app parses the outer wrapper and validates function name and argument schema; the app executes the registered tool implementation; and only after a verified success does the app commit the change to UI state.
The article’s small in-memory fixture separates responsibilities so you can unit-test the ordering that matters: parseToolCall → validateFunctionCall → runToolBoundaryWorkflow → commit. That split makes it possible to assert distinct failure modes — malformed wrapper, missing required arguments, and tool-level cancellation — rather than collapsing everything into one integration assertion.
Expected behavior
- Malformed or non-JSON proposals are rejected and never reach the tool.
- Valid proposals are parsed; arguments are JSON-decoded and checked against an allowed-keys list and required keys.
- The tool executes with validated arguments and can return a normal result or an explicit cancellation shape (e.g., an object containing cancelled: true and a reason).
- A single commit callback is called only for the successful path; cancelled or invalid proposals do not call commit.
Limitations
- This fixture is intentionally local and deterministic: it does not simulate the Responses API, model selection, or network transport. It verifies the app-side contract (validation → execution → commit) but does not replace server-side authorization checks, durable audit logging, or provider-specific handling.
- Keeping sensitive authorization decisions in the application layer remains essential; the model should not be trusted to decide whether an action is allowed.
If you want a Typescript port or an example wiring this pattern into an Express/Next.js endpoint, I can prepare one. (The server will append the canonical link and the verified code example.)
Working example
export function parseToolCall(raw) {
if (typeof raw !== 'string') {
throw new TypeError('tool call must be a JSON string');
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new SyntaxError('tool call is not valid JSON');
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new TypeError('tool call must be a JSON object');
}
return parsed;
}
export function validateFunctionCall(call, schema) {
if (!call || typeof call !== 'object' || Array.isArray(call)) {
throw new TypeError('call must be an object');
}
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
throw new TypeError('schema must be an object');
}
const { name, arguments: args } = call;
const { name: expectedName, required = [], allowedKeys = [] } = schema;
if (typeof name !== 'string' || name.length === 0) {
return { ok: false, reason: 'missing function name' };
}
if (name !== expectedName) {
return { ok: false, reason: `unexpected function name: ${name}` };
}
if (typeof args !== 'string') {
return { ok: false, reason: 'missing arguments string' };
}
let parsedArgs;
try {
parsedArgs = JSON.parse(args);
} catch {
return { ok: false, reason: 'arguments are not valid JSON' };
}
if (!parsedArgs || typeof parsedArgs !== 'object' || Array.isArray(parsedArgs)) {
return { ok: false, reason: 'arguments must decode to an object' };
}
for (const key of required) {
if (!(key in parsedArgs)) {
return { ok: false, reason: `missing required argument: ${key}` };
}
}
for (const key of Object.keys(parsedArgs)) {
if (!allowedKeys.includes(key)) {
return { ok: false, reason: `unexpected argument: ${key}` };
}
}
return { ok: true, arguments: parsedArgs };
}
export function runToolBoundaryWorkflow({ rawToolCall, schema, tools, commit }) {
if (!tools || typeof tools !== 'object') {
throw new TypeError('tools map must be provided');
}
if (typeof commit !== 'function') {
throw new TypeError('commit must be a function');
}
const call = parseToolCall(rawToolCall);
const validation = validateFunctionCall(call, schema);
if (!validation.ok) {
return { status: 'rejected', reason: validation.reason };
}
const tool = tools[schema.name];
if (typeof tool !== 'function') {
return { status: 'rejected', reason: `no tool registered for ${schema.name}` };
}
const result = tool(validation.arguments);
if (result && typeof result === 'object' && result.cancelled) {
return { status: 'cancelled', reason: result.reason || 'tool cancelled' };
}
const committed = commit({ name: schema.name, input: validation.arguments, output: result });
return { status: 'committed', result: committed };
}
export function createFixtureTool(name, behavior) {
if (typeof behavior !== 'function') {
throw new TypeError('behavior must be a function');
}
return (args) => behavior(args, name);
}
Read the full article: https://chriseugenerodriguez.com/blog/function-calling-boundaries-openai
Top comments (1)
proposals not permissions is the right frame. the case that bites us on open models is the one in between, json that parses and passes the allowed keys check but the values are subtly off, like a string where u wanted cents. do u validate value types at the boundary too or only keys? (i build grunz, a coding agent on open models)