How-to › Use AI to do the integration

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.

Audience
Agent builder
Level
intermediate
Topic
Build an agent that calls APIs
Languages
TypeScript and Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-written loopOne agent, a few tools, and a team that wants to read every line on the pathEvery convenience is yours to write and test: the catch around each tool, the step cap, any handoff, any traceYou want handoffs or traces without building them
OpenAI Agents SDK function toolsSeveral agents handing off to each other, with traces from the first runHandoffs, hooks, and spans all speak this SDK’s vocabulary, so the tool layer moves with it or not at allPortability across providers matters more than the conveniences
Vercel AI SDK tool helperOne loop over many providers, with the model chosen per callNo handoff primitive, and traces mean registering an OpenTelemetry integration and a tracer yourselfMulti-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.

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

  1. 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 becomes transfer_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 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. ↩︎ Back to text

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

  3. 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 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.