You already have a typed client for the API, and the agent needs to call it. The first draft wraps each method in a tool by hand, and the second agent needs the same client. Then the first 404 from the API ends the run with a stack trace, because the exception left the tool before the model could read it.
What you get
You will end up with three operations as typed function tools, two agents sharing one client across a handoff, and a test showing a failed call reaches the model. This is for you if you build agents over a client you already have.
Short answer
Wrap each client method in tool() with a Zod schema for its arguments and an execute that
calls the method, then hand the tools to an Agent that run() drives to completion. Capture the
client in a closure so a second agent, reached through handoff(), calls the same instance.
Keep the default errorFunction: it turns a thrown error into text the model reads, and setting
it to null ends the run at the first exception.
You will need
Node 22 or later and a typed client for your API; the Python twin needs Python 3.11 or later. Verified 2026-09-24 against Node 22.22.2, @openai/agents 0.18.0, ai 7.0.113, Zod 4.6.5, and openai-agents 0.22.3. Every run on this page uses a scripted model that implements the SDK’s Model interface and follows a fixed policy, so no request leaves the machine and no API key is set. The loop, the error handling, the handoff, and the tracing are the SDK’s own.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-written loop | One agent, a few tools, and a team that wants to read every line on the path | Every convenience is yours to write and test: the catch around each tool, the step cap, any handoff, any trace | You want handoffs or traces without building them |
| OpenAI Agents SDK function tools | Several agents handing off to each other, with traces from the first run | Handoffs, hooks, and spans all speak this SDK’s vocabulary, so the tool layer moves with it or not at all | Portability across providers matters more than the conveniences |
| Vercel AI SDK tool helper | One loop over many providers, with the model chosen per call | No handoff primitive, and traces mean registering an OpenTelemetry integration and a tracer yourself | Multi-agent flows are the point of the work |
The Agents SDK gives you the handoff and the trace for free and charges you in coupling: the tool layer is written in its terms. The AI SDK keeps the provider interchangeable and leaves multi-agent flow to you. The loop costs nothing to adopt and everything to maintain, one convenience at a time. All three showed the model the failed call in the runs below, and only one of them did so without a line of your code.
Turn each operation into a tool
Each operation becomes one tool() call: a name, a description the model reads, a Zod schema
for the arguments, and an execute that calls the client.
const listMeters = tool({
name: 'listMeters',
description: 'List every meter, one page at a time.',
parameters: z.object({ cursor: z.string().nullable() }),
execute: ({ cursor }) => client.listMeters({ cursor: cursor ?? undefined }),
...options,
})
const getMeter = tool({
name: 'getMeter',
description: 'Fetch one meter by id.',
parameters: z.object({ id: z.string() }),
execute: ({ id }) => client.getMeter({ id }),
...options,
})
The cursor is nullable() rather than optional(). A Zod schema turns on
strict mode. OpenAI’s
strict function calling lists
every property as required, so an argument the model may leave out has to be one it can pass as
null. The Zod schema is also the validator. The SDK checks the model’s
arguments against it before execute runs, and a mismatch goes back to the model as an error
rather than into your client. execute returns the client’s object and the SDK serializes it,
so the model reads the same JSON your code does.
The ...options spread exists for the pitfall section, which passes errorFunction: null
through it.
Python’s decorator reads the same three things from the function itself. The signature gives the argument names and types, the docstring gives the description, and the Python SDK builds the schema.
@function_tool
async def list_meters(cursor: str | None) -> str:
"""List every meter, one page at a time."""
return json.dumps(await client.list_meters(cursor))
@function_tool
async def get_meter(id: str) -> str:
"""Fetch one meter by id."""
return json.dumps(await client.get_meter(id))
Share one client across a handoff
Both agents close over the same client. Nothing passes through the run context and nothing is
global; the second agent calls the instance the first one used because its tools were built in
the same scope.
const readingsAgent = new Agent({
name: 'Readings agent',
instructions: 'Record the reading you were handed and report its id.',
tools: [createReading],
model,
})
// The handoff carries typed input, and onHandoff runs before the readings agent does.
const handedOff = []
const toReadings = handoff(readingsAgent, {
inputType: z.object({ meterId: z.string() }),
onHandoff: (context, input) => { handedOff.push(input) },
})
const meterAgent = new Agent({
name: 'Meter agent',
instructions: 'Find the meter the user means, then hand off to record a reading.',
tools: [listMeters, getMeter],
handoffs: [toReadings],
model,
})
return { meterAgent, readingsAgent, handedOff }
handoff() wraps the target agent
and presents it to the model as one more tool, named transfer_to_ plus the agent’s name.1
inputType gives that tool a typed argument, so the meter id the first agent found travels with
the handoff instead of being re-read from the conversation. onHandoff runs before the second
agent’s first turn, which is where a guard or an audit line belongs.
Run the meter agent with a trace processor that prints spans to stdout in place of the default exporter:
node demo.mjs
what the model saw, turn by turn
call getMeter({"id":"mtr-404"})
result An error occurred while running the tool. Please try again. Error: NotFoundError: 404 no meter with id mtr-404
call listMeters({"cursor":null})
result {"items":[{"id":"mtr-101","site":"Dublin","unit":"kWh"},{"id":"mtr-102","site":"Cork","unit":"kWh"}],"nextCursor":null}
call getMeter({"id":"mtr-101"})
result {"id":"mtr-101","site":"Dublin","unit":"kWh"}
call transfer_to_Readings_agent({"meterId":"mtr-101"})
handoff Meter agent -> Readings agent
call createReading({"meterId":"mtr-101","value":42.5})
result {"id":"rd-1","meterId":"mtr-101","value":42.5}
final Recorded reading rd-1 for mtr-101.
last agent: Readings agent
final output: Recorded reading rd-1 for mtr-101.
calls on the one client instance: getMeter, listMeters, getMeter, createReading
spans the SDK emitted, with no tracing code in agents.mjs
function getMeter
turn
function listMeters
turn
function getMeter
turn
handoff Meter agent -> Readings agent
turn
agent Meter agent
function createReading
turn
turn
agent Readings agent
task Agent workflow
Read the second line first. The getMeter tool threw, and what the model saw was a sentence:
the SDK’s default error text with the exception appended. The policy read it and asked for the
list instead, which is the recovery this page is about. The handoff appears twice, as a tool
call the model made and as a span the SDK recorded with the source and target agent. Fourteen
spans came out of a file that never mentions
tracing: one per tool call, one per
model turn, one for the handoff, one per agent, and one for the task.
Let the model see the failure
The Agents SDK catches what a tool throws and hands the model a sentence. Set errorFunction to
null and the same exception escapes the tool, the SDK wraps it in a ToolCallError, and
run() rejects.
const { meterAgent } = buildAgents(client, new ScriptedModel(), { errorFunction: null })
node pitfall.mjs
the run threw: ToolCallError: Failed to run function tools: NotFoundError: 404 no meter with id mtr-404
calls on the client before it ended: getMeter
readings recorded: 0
The run threw after one call and recorded no reading, leaving a stack trace for the person who
asked. The model never had the chance
to list the meters, because the failure went up the stack instead of into the conversation. A
404 from your API is information: the id was wrong, and the model can act on that. The Python
SDK draws the same line with
failure_error_function: the default
tells the model an error occurred, and passing None re-raises.
The default message tells the model to try again.2 For a 404 that advice is wrong, and a
model that follows it repeats the same call. Pass your own errorFunction once the run works,
returning the status and the message and nothing about retrying.
Check it worked
Six tests, and the one that matters asserts the wrap: the run rejects with the SDK’s error, the
original NotFoundError sits on its error property, and the client saw exactly one call.
test('errorFunction: null ends the run on the first thrown error', async () => {
const client = new MeterClient()
const { meterAgent } = agents.buildAgents(client, new agents.ScriptedModel(), { errorFunction: null })
// The SDK wraps the escaped error in its own ToolCallError and keeps the original on .error.
await assert.rejects(() => agents.runMeterTask(meterAgent, task), (err) => {
assert.equal(err.name, 'ToolCallError')
assert.equal(err.error.name, 'NotFoundError')
return true
})
assert.deepEqual(client.calls.map(([op]) => op), ['getMeter'], 'nothing after the failure ran')
assert.equal(client.readings.length, 0)
})
node --test agents.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 984.419736
The other five pin what the demo showed, with the
Node test runner. The first result carries the 404, the
handoff carried { meterId: 'mtr-101' }, both agents’ calls landed on one client, and the two
other loops below recover as well.
The same three operations, twice more
The Vercel AI SDK tool helper takes an
inputSchema and an execute, and generateText runs the loop for as many steps as stopWhen
allows.
getMeter: tool({
description: 'Fetch one meter by id.',
inputSchema: z.object({ id: z.string() }),
execute: ({ id }) => client.getMeter({ id }),
}),
A thrown error becomes a tool-error part in the step, and the next step sends it to the model
as error-text. Its
tool calling guide describes that
as the mechanism for automated round trips. The mock model comes from
ai/test. What the AI SDK does not have is a
handoff, and its telemetry is opt-in: you
install @ai-sdk/otel, register it, and supply an
OpenTelemetry tracer.
The hand-written loop has the one line the others hide:
try {
const output = await tool.execute(decision.args)
transcript.push({ kind: 'result', name: decision.call, failed: false, output })
} catch (err) {
transcript.push({ kind: 'result', name: decision.call, failed: true, output: String(err) })
}
Leave the catch out and the loop behaves like errorFunction: null. Leave the step cap out
and a model that keeps retrying never returns.
node compare.mjs
wiring error reaches model run finished last agent spans unasked readings
OpenAI Agents SDK true true Readings agent 14 1
Vercel AI SDK true true none 0 1
hand-written loop true true none 0 1
All three let the model read the failure, and all three finished with one reading recorded. One wiring handed off, and one emitted spans without being asked. That column is the trade: the spans are free because the SDK owns the loop, and the SDK owning the loop is what binds the tool layer to it.
When it goes wrong
run() rejects with ToolCallError and you never set errorFunction. A tool with an
outputSchema disables the default handler, because free text cannot satisfy a structured
output, so the SDK throws it again. Give that tool an errorFunction that returns a value matching its
schema.
A custom model crashes inside the runner at inputTokensDetails.reduce. The runner reads the
SDK’s own Usage class from every response, so a plain object with the same fields is not
enough. Return new Usage({ ... }) from getResponse, as agents.mjs does.
The handoff never fires. The tool is named from the agent’s display name with spaces and
punctuation replaced by underscores and the case kept, so Readings agent becomes
transfer_to_Readings_agent. Read the name from the request the SDK builds rather than typing
it, or set toolNameOverride.
Traces go to OpenAI. Tracing is on by default in server runtimes and exports there, so adding a
processor of your own still exports unless you replace the list with setTraceProcessors or set
OPENAI_AGENTS_DISABLE_TRACING=1.3
When not to do this
Do not adopt the Agents SDK for one agent and three tools if you expect to change providers.
The loop is forty lines. The SDK’s conveniences are the things that do not port: handoffs,
hooks, and spans are its own vocabulary, and a tool layer written in it moves with it. The AI
SDK’s tool() is the portable shape when portability is the requirement.
Do not let the default errorFunction stand in for error handling. It shows the model every
exception the same way, a bug in your own code included, with an instruction to try again. A
programming error deserves a rejected run and a stack trace; a 404 deserves a sentence. Write an
errorFunction that tells them apart once the wiring works.
Do not share one client instance across agents that act for different users. The closure that makes the handoff cheap also makes the credentials common, and the second agent inherits whatever the first one was allowed to see. Build the tools per request, with the client for that request, when authorization differs between callers.
Do not write the loop without a step cap. The one on this page gives up after eight steps, and a model that repeats a failing call is the reason.
Related how-tos
- Measure drift between two AI-written clients
- Name tools so an agent picks the right one from fifty
- Run an MCP server against a mock API while you build tools
Last verified
Verified 2026-09-24 against Node 22.22.2, @openai/agents 0.18.0, ai 7.0.113, and Zod 4.6.5.
Every output block is what the command preceding it printed. Every run used the scripted model
in place of a provider, so no request left the machine; the loops, the error handling, the
handoff, and the spans are the SDKs’ own. tools.py was run once against openai-agents 0.22.3
on Python 3.11.15 with the same script and printed the same final output; the gate re-runs the
Node commands only.
Footnotes
-
Both SDKs document the name as
transfer_to_<agent_name>and both derive it from the display name. The JavaScript guide says a handoff to Refund Agent becomestransfer_to_refund_agent, and the Python guide gives the same example. The Python SDK folds the result to lowercase and the JavaScript SDK does not, which is why the run on this page showstransfer_to_Readings_agent. The two documents agree, the two implementations differ by one capital, and a model would never notice while a string comparison always will. ↩︎ Back to text -
The default handler in
@openai/agentsreturns the sentence shown in the demo output, an error notice with the exception appended, which its tools guide describes as a model-visible result. The advice to try again is addressed to the model, and the model on this page took it, listing the meters and trying again with an id that existed. The sentence was written for the general case and cannot tell a dropped connection from a missing record. That makes it the one line of English in the SDK that a run’s outcome can turn on. ↩︎ Back to text -
The tracing guide says tracing is on by default in server runtimes, off in browsers and when
NODE_ENV=test, and exported to OpenAI by the default setup. Two switches turn it off, an environment variable andtracingDisabledon the runner, and the guide lists both without preferring either. A test suite that setsNODE_ENV=testgets a third switch it never asked for. ↩︎ Back to text