# How to wire an API client into the OpenAI Agents SDK

> Register a typed client's operations as function tools, run the loop, hand off between two agents on one client, and keep a failed call from ending the run.

Source: https://voxgig.com/howto/wire-an-api-client-into-openai-agents-sdk

- Audience: agent-builder
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://openai.github.io/openai-agents-js/guides/models/) 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](https://developers.openai.com/api/docs/guides/function-calling) | 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](https://openai.github.io/openai-agents-js/guides/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](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) | 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.

```ts title="agents.mjs"
  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](https://openai.github.io/openai-agents-js/guides/tools/). OpenAI's
[strict function calling](https://developers.openai.com/api/docs/guides/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](https://zod.dev/) 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](https://openai.github.io/openai-agents-python/tools/) builds the schema.

```python title="tools.py"
    @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.

```ts title="agents.mjs"
  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()`](https://openai.github.io/openai-agents-js/guides/handoffs/) 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:

```bash
node demo.mjs
```

```text output
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](https://openai.github.io/openai-agents-js/guides/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.

```ts title="pitfall.mjs"
const { meterAgent } = buildAgents(client, new ScriptedModel(), { errorFunction: null })
```

```bash
node pitfall.mjs
```

```text output
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`](https://openai.github.io/openai-agents-python/tools/): 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.

```ts title="agents.test.mjs"
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)
})
```

```bash
node --test agents.test.mjs
```

```text output
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](https://nodejs.org/api/test.html). 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](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool) tool helper takes an
`inputSchema` and an `execute`, and `generateText` runs the loop for as many steps as `stopWhen`
allows.

```ts title="vercel.mjs"
    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](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) describes that
as the mechanism for automated round trips. The mock model comes from
[`ai/test`](https://ai-sdk.dev/docs/ai-sdk-core/testing). What the AI SDK does not have is a
handoff, and its [telemetry](https://ai-sdk.dev/docs/ai-sdk-core/telemetry) is opt-in: you
install `@ai-sdk/otel`, register it, and supply an
[OpenTelemetry](https://opentelemetry.io/docs/) tracer.

The hand-written loop has the one line the others hide:

```ts title="loop.mjs"
    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.

```bash
node compare.mjs
```

```text output
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](/howto/measure-drift-between-two-ai-written-clients)
- [Name tools so an agent picks the right one from fifty](/howto/name-tools-so-agents-pick-the-right-one)
- [Run an MCP server against a mock API while you build tools](/howto/run-an-mcp-server-against-a-mock-api)

## 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.

[^1]: Both SDKs document the name as `transfer_to_<agent_name>` and both derive it from the
display name. The [JavaScript guide](https://openai.github.io/openai-agents-js/guides/handoffs/)
says a handoff to Refund Agent becomes `transfer_to_refund_agent`, and the
[Python guide](https://openai.github.io/openai-agents-python/handoffs/) 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 shows `transfer_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.

[^2]: The default handler in `@openai/agents` returns the sentence shown in the demo output, an
error notice with the exception appended, which its
[tools guide](https://openai.github.io/openai-agents-js/guides/tools/) 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.

[^3]: The [tracing guide](https://openai.github.io/openai-agents-js/guides/tracing/) 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 and
`tracingDisabled` on the runner, and the guide lists both without preferring either. A test
suite that sets `NODE_ENV=test` gets a third switch it never asked for.