Skip to main content
Build with Inferbase · Agents

Agents with tools and MCP

An operations agent whose tools are an allow-list in the policy, whose MCP servers are declared, whose consequential actions wait for approval, and whose runs are bounded and recorded. Your client calls the tools, or Inferbase calls the declared servers itself. Built on the Responses API.

What you build

An agent that acts on real systems, here an order system, through tools: it reads an order, changes an address, issues a refund, and reports what it did. The tools it may call are a list in the routing policy, taken from a dry run to enforced; the MCP servers they come from are declared, so a tool from nowhere is drift; actions with consequences wait for a person to approve them; and every run is one session with caps on requests and spend and a record of each call. The service is built on the Responses API, the shape the OpenAI Agents SDK sends, so an existing agent moves over with its base URL. The last step hands tool execution to Inferbase for a declared server, so the application never holds that server's credential.

When you are done

  • An agent loop on the Responses API with four operations tools, one session per run
  • A tool surface that is policy: dry-run reports first, then enforced refusals before any model is chosen
  • Declared MCP servers, so the policy allows tools by server and undeclared tools are visible
  • An approval gate: reads run at once, writes wait for a person
  • Runs that stop on the policy caps and are read back as one agent run with its tool calls

The components

Agent service
Receives a task, mints a session id, runs the loop on the Responses API, executes or brokers the tools, and returns the outcome.
Tool executor
Thin functions over your systems: lookup_order, update_address, issue_refund, add_note. Each declares whether it is a read or a write.
Approval gate
A write is held until a person approves it, through your chat or ticket surface; the run resumes on its model with the session kept.
Routing policy
The tool-capable pool, the allow-list and its strictness, the declared servers, who calls the MCP tools, and the caps per session.
Declared MCP servers
Under Connections: each server's name, and for brokered servers its endpoint and token. Inferbase reads the tool list from each.
Run store
Postgres: one row per run, one per call with its request id, model and tool calls, one per approval.
Inferbase
The Responses and chat endpoints, routing among tool-capable models, the allow-list enforced at request time, and for brokered servers the tool execution itself.
Evaluation
A task set with the expected tool sequence and outcome; scored on success, unwanted tools, steps and cost.

The problem

An agent is only as safe as the list of tools it may call, and that list is usually nowhere.

A tool-using agent sends the model a list of functions and executes what the model asks for. In most deployments that list lives in the application code, changes with every deploy, and is seen by nobody who is responsible for what the agent may do. An MCP client makes it worse in one way: it merges every connected server into one flat list, so a tool that creates an issue and one that deletes a branch arrive side by side with no record of where either came from.

The second problem is the loop. An agent that keeps calling tools has no natural end, and the usual stop is a counter in the code. A counter does not bound spend, does not survive a bug in the loop, and leaves no record of why a run stopped.

The third is consequence. A read is cheap to get wrong; a refund is not. An agent that treats them alike either asks about everything or asks about nothing.

This guide moves the list into the policy. The tools a key may send are allow-listed, first in a dry run that reports what it would refuse, then enforced. MCP servers are declared, so a policy can allow tools by server and a tool from no declared server shows as drift. Writes wait for a person. One session identifier per run groups its calls, and the policy caps on requests and spend end a run that does not end itself. For servers you declare with a credential, Inferbase can call the tools itself, so the application never holds them.

Who has it

  • A team shipping an agent that acts on real systems: tickets, orders, repositories, calendars
  • A platform team that has to answer which tools which application may call
  • Anyone running MCP servers who wants the credential out of the application

Architecture

System overview

Every component, grouped by who runs it. The product sits at the generation call; everything in your lanes is yours and unchanged by this design.

PEOPLEOperatorGives the task in a chat or aticketApproverConfirms writes before they runAGENT SERVICEAgent loopResponses API, one session perrunTool executorReads run; writes wait forapprovalMCP clientFlattens declared servers' toolsRun storeCalls, tool calls, approvalsINFERBASERouting policyAllow-list, declared servers,caps/responsesmodel auto, tools, session headerBrokerCalls declared servers when thepolicy says soSYSTEMS AND SERVERSOrder systemBehind your executorDeclared MCP serverRemote, HTTPS, token held byInferbaseTool-capable modelsWinner per calltaskinput items + toolsservefunction callsread / writewrite awaiting approvalclient-called toolsbrokered toolsper callFigure 1. Two ways to reach a tool. Your executor and MCP client call them with your credentials; the broker calls a declared server with a credentialInferbase holds. The policy governs both the same way: by tool name and by server.inferbase.ai

One request, end to end

The tool list is policy, not code. Execution stays with your client by default; a declared server with a credential can be called by Inferbase instead.

01 Agentyour stackYour loop, on the Responses orchat shape, with the tool list itdeclares.- one session id per run- tools from your code or MCP clients02 InferbaseInferbaseRouting among tool-capablemodels, inside a governed toolsurface.- allow-list: any, dry run, enforce- declared servers; undeclared is drift- loop stays on its model- request and spend caps per session03 Toolsyour stackExecuted by your client, or byInferbase against declared MCPservers.- your systems and credentials- or a declared server with its token04 The runInferbaseOne agent run under Usage: eachcall, its model, its tools, itscost, and why it stopped.Figure 2. The same request as a flow: what each stage decides and hands on.inferbase.ai
One task, with an approval and a governed tool list
  1. 01OperatorAgent serviceGives the task: change the delivery address on order 4217 if it has not shipped.A session id is minted for the run.
  2. 02Agent serviceInferbase /responsesThe first call: model auto, instructions, the task as input, four tools declared, the session in the X-Inferbase-Session header.The Responses shape has no routing field, so the session rides in the header.
  3. 03InferbaseInferbaseChecks the four tool names against the allow-list (Dry run: serve and report; Enforce: refuse), keeps only tool-capable models, routes.A refusal is a 403 naming the tool, before any model is chosen.
  4. 04InferbaseAgent serviceOutput items: a function_call for lookup_order with the order id.The response names the model that served.
  5. 05Agent serviceOrder systemExecutes the read at once and appends a function_call_output.Reads need no approval.
  6. 06Agent serviceInferbase /responsesThe next call with the whole transcript: the task, the call, the output.The endpoint keeps no state; each call carries everything. The loop stays on the model that opened it.
  7. 07InferbaseAgent serviceA function_call for update_address.A write.
  8. 08Agent serviceApproverHolds the call and asks: change the address on 4217 to 12 Rue des Lilas, Lyon?The run is paused; the session is kept.
  9. 09ApproverAgent serviceApproves.A rejection is returned to the model as the tool output, so it can explain.
  10. 10Agent serviceOrder systemExecutes the write; appends the output; one more call to the API for the final message.Three model calls in all.
  11. 11Agent serviceOperatorReports what it did, with the run id.The run is under Usage as one agent run with its three calls and two tool calls.
Figure 3. The gateway governs names; your service governs consequences. Together: no tool outside the list is ever called, and no write runs without a person.

Deployment

Where each part runs, and what talks to what over the network.

PEOPLE AND CHANNELSChat, ticket system, CLIWhere tasks and approvals happenAGENT SERVICEServiceOne process; a queue for runsPostgresruns, calls, approvalsINFERBASEapi.inferbase.airesponses, chat; policy; broker;agent runsSYSTEMS AND SERVERSYour systemsCalled by the service with itscredentialsRemote MCP serversHTTPS; called by the service orby the brokertasks, approvalsrecordHTTPS, bearer key, session headertool executionclient-calledbrokered, with the stored tokenFigure 4. In client mode the service holds every credential. In brokered mode the declared server's token lives with Inferbase and the service holds onlythe API key. Both modes are recorded the same way.inferbase.ai

What stays in your stack

  • The loop and the prompts
  • Tool implementations, when your client calls them
  • The systems the tools act on
  • Approval steps for actions with consequences

Technology stack

One choice per layer, the alternative that was considered, and the reason. Swap any row; the Inferbase call does not change.

LayerChoiceAlternativesWhy
LanguagePython 3.12TypeScript with the same SDKThe Responses shape and the MCP client SDK are first-class in both; Python is where most operations tooling lives.
API shapeThe Responses API through the openai SDKChat completions; the OpenAI Agents SDK; the Anthropic Messages shapeThe item list carries function calls and outputs as a transcript, which is what the Agents SDK sends and what the approval gate needs to pause and resume.
ToolsNamed functions with a read or write flagA generic execute toolNames are what the allow-list governs and the record shows; the flag is what the approval gate reads.
MCP, client modeThe MCP Python SDK, servers declared under ConnectionsNo MCP; HTTP clients per systemThe client flattens tools into the same function shape; declaring the servers lets the policy allow by server and see drift.
MCP, brokered modeDeclared servers with a token, policy set to Inferbase calls themClient modeThe credential leaves the application, the allow-list applies at execution, tool latency is measured, and a stop stops mid call.
ApprovalsA pending-approval row and a message in your chat or ticket surfaceApprove nothing; approve everythingWrites with consequences are few; a human on each costs seconds and prevents the one bad refund.
Run storePostgres: runs, calls, tool calls, approvalsSQLiteApprovals need durable state; the run record is how a question about a past action is answered.
GovernanceThe policy's allow-list, declared servers, who-calls setting and session capsChecks in codePolicy has a revision history, applies to every key, and cannot be bypassed by a deploy.

What carries it

The Inferbase features this design rests on. Each exists in the product today and is linked to its documentation.

The tool allow-list
Tools the app may call, with a strictness: Any tool, Dry run (served and reported), or Enforce (refused with a 403 before any model is chosen). Every tool a project sends is recorded with when it was first seen. Documentation
Declared MCP servers
Declare a server under Connections and the policy can allow tools by server. A tool no declared server claims is drift, and the policy says whether it is served, served and recorded, or refused. Documentation
Inferbase calls the tools
Give a declared server its endpoint and a credential and Inferbase becomes the MCP client: it fetches the tool list, executes the calls the model asks for, and loops until the model has an answer. The application never holds the credential. Documentation
Tool-capable models and a stable loop
A tool-carrying request reaches only models with confirmed tool support, and a conversation mid tool loop stays on the model that made the call. Documentation
Session caps
The policy caps the requests and the spend one session may use. A run that reaches the request cap gets a 429 and one that reaches the spend cap a 402, which is the stop the loop may have forgotten. Documentation
The Responses API
The shape the OpenAI Agents SDK and Pydantic AI send, over the same router, with function calls and their outputs in the input list. The session rides in the X-Inferbase-Session header. Hosted tools are refused by name. Documentation
Guardrails on tool results
A guardrail policy can run the injection check at the tool-results door, so text a tool returned is inspected before the model reads it. Attacks mostly arrive there. Documentation
Agent runs under Usage
Requests grouped by session, with the tool calls per run; Usage › Tools lists every tool each project has sent. A brokered run adds usage.broker: turns, tools executed, failures, servers, why it stopped. Documentation

Build it step by step

Eight steps from a tool list in code to a governed, approved, recorded agent, ending with the brokered mode. The code is Python on the Responses API; the loop alone is also given in TypeScript after the policy, and both were run against the API. The brokered excerpt in step 8 follows the documentation and was not run. Each step ends with the thing it turns on.

Before you start

  • An Inferbase account, a project, and a key with a spend cap for this service.
  • The systems the tools act on, reachable from the service with its own credentials; a remote MCP server over HTTPS with a token if you will declare one.
  • Python 3.12 with openai; the mcp package for client mode; Postgres for the run store.

Step 1: Create the routing policy with the allow-list in dry run

Build the pool by rule: supports tool calling. Under Request, set the allow-list to Dry run with the four tool names, choose the declared servers the tools may come from (none yet), set tools from an undeclared server to served and recorded, and leave who calls the MCP tools on your client. Under Fallback, refuse tool requests the pool cannot serve. Under Guardrails, name a guardrail policy with the injection check at the tool-results door in record mode. Under Session, cap requests and spend to the bound of one run. Attach to the key.

Dry run reports a tool outside the list with the tool and the key. Those reports are the list you will enforce in step 7; writing the list from the code instead misses the names the model invents.

Step 2: Define the tools with a read or write flag

Four operations tools in the Responses shape: lookup_order and add_note are reads, update_address and issue_refund are writes. The flag lives beside the definition and is what the gate reads. Keep descriptions plain and parameters minimal; a tool is a verb the policy can name.

agent/tools.py
python
TOOLS = [
    {"type": "function", "name": "lookup_order", "description": "Fetch an order by id.", "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"]}},
    {"type": "function", "name": "add_note", "description": "Add an internal note to an order.", "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}, "note": {"type": "string"}}, "required": ["order_id", "note"]}},
    {"type": "function", "name": "update_address", "description": "Change the delivery address of an order that has not shipped.", "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}, "address": {"type": "string"}}, "required": ["order_id", "address"]}},
    {"type": "function", "name": "issue_refund", "description": "Refund an order, in full or in part.", "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}, "amount": {"type": "number"}}, "required": ["order_id", "amount"]}},
]

WRITES = {"update_address", "issue_refund"}  # these wait for a person


def execute(name: str, args: dict) -> str:
    """Your systems. Returns text the model reads; keep it short and factual."""
    if name == "lookup_order":
        return orders.get(args["order_id"]).model_dump_json()
    if name == "add_note":
        return orders.add_note(args["order_id"], args["note"])
    if name == "update_address":
        return orders.update_address(args["order_id"], args["address"])
    if name == "issue_refund":
        return orders.refund(args["order_id"], args["amount"])
    return json.dumps({"error": f"unknown tool {name}"})

Step 3: The loop on the Responses API

Each call carries the whole transcript as input items: the task, every function call and every output so far. The session id rides in the header because the Responses shape has no routing field. When the model answers without a function call, the run is complete; a 429 or 402 from the gateway is the policy cap, and the run is complete in a different way.

agent/loop.py
python
import json
from openai import OpenAI, APIStatusError

client = OpenAI(api_key="inf_your_api_key", base_url="https://api.inferbase.ai/api/v1/inference")

INSTRUCTIONS = "You are an operations agent. Use the tools; never guess an order's state. Explain what you did at the end."


def run(task: str, run_id: str, store: RunStore, approvals: Approvals, max_turns: int = 8) -> str:
    items = [{"role": "user", "content": task}]
    for _ in range(max_turns):
        try:
            response = client.responses.create(
                model="auto",
                instructions=INSTRUCTIONS,
                input=items,
                tools=TOOLS,
                max_output_tokens=500,
                extra_headers={"X-Inferbase-Session": run_id},
            )
        except APIStatusError as exc:
            if exc.status_code in (402, 429):
                store.finish(run_id, outcome=f"stopped by cap: {exc.message}")
                return "I had to stop: the run reached its limit."
            raise  # a 403 naming a tool: the list is wrong or the loop is
        store.call(run_id, response.id, response.model, [i.name for i in response.output if i.type == "function_call"])
        calls = [i for i in response.output if i.type == "function_call"]
        if not calls:
            store.finish(run_id, outcome="complete")
            return response.output_text
        for call in calls:
            items.append(call)
            output = gated_execute(call.name, json.loads(call.arguments), run_id, approvals)
            items.append({"type": "function_call_output", "call_id": call.call_id, "output": output})
    store.finish(run_id, outcome="max_turns")
    return "I could not finish within the step limit."

Step 4: The approval gate

A read executes at once. A write is written to the approvals table and sent to the approver through your channel; the loop waits for the decision. An approval executes the tool and returns its output; a rejection returns a sentence as the tool output, so the model can tell the operator what did not happen and why. The session id is unchanged across the wait, so the run resumes on its model.

agent/gate.py
python
def gated_execute(name: str, args: dict, run_id: str, approvals: Approvals) -> str:
    if name not in WRITES:
        return execute(name, args)
    approval_id = approvals.request(run_id, name, args)  # a row, and a message to the approver
    decision = approvals.wait(approval_id, timeout_s=600)  # your channel answers it
    if decision.approved:
        result = execute(name, args)
        approvals.record(approval_id, result)
        return result
    return json.dumps({"declined": True, "reason": decision.reason or "not approved"})

The model sees a declined write as a tool output, not as an error. It explains the outcome to the operator instead of retrying the write, which is the behaviour you want from an agent that was told no.

Step 5: Declare the MCP servers

When some tools come from MCP servers, declare each under Gateway › Connections › MCP servers with its name. Declaring tells Inferbase which tools the server offers, so the policy can allow tools by server, and a tool no declared server claims shows as drift. In client mode your service still runs the MCP client and merges the servers' tools into the same tools array; the declaration is what gives the merged list its provenance back.

agent/mcp_tools.py
python
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client


async def tools_from(server_url: str, token: str) -> list[dict]:
    """Flatten a declared server's tools into the Responses function shape."""
    async with streamablehttp_client(server_url, headers={"Authorization": f"Bearer {token}"}) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            listed = await session.list_tools()
            return [
                {"type": "function", "name": t.name, "description": t.description or "", "parameters": t.inputSchema}
                for t in listed.tools
            ]


# TOOLS = TOOLS + await tools_from("https://mcp.your-ticketing.example/mcp", TICKETING_TOKEN)

Step 6: Record the run

One row per run, one per call with the request id, the model and the tool names, one per approval. The gateway keeps the same facts as one agent run grouped by the session id, with every tool declared and called; your table adds the approvals and the business outcome. When a question about a past action comes, both agree, and the request id reads back the decision.

schema.sql
sql
CREATE TABLE runs (
  run_id     text PRIMARY KEY,
  task       text NOT NULL,
  outcome    text,                       -- complete | max_turns | stopped by cap | error
  started_at timestamptz NOT NULL DEFAULT now(),
  ended_at   timestamptz
);

CREATE TABLE calls (
  id         bigserial PRIMARY KEY,
  run_id     text NOT NULL REFERENCES runs,
  request_id text NOT NULL,              -- reads back the routing decision
  model      text NOT NULL,
  tools      text[] NOT NULL,            -- function calls the model made on this turn
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE approvals (
  id         bigserial PRIMARY KEY,
  run_id     text NOT NULL REFERENCES runs,
  tool       text NOT NULL,
  args       jsonb NOT NULL,
  decision   text,                       -- approved | declined
  decided_by text,
  result     text,
  created_at timestamptz NOT NULL DEFAULT now(),
  decided_at timestamptz
);

Step 7: Take the allow-list to Enforce

After a week of dry run, read the reports under Usage: every tool served outside the list, with the key. Add what belongs, fix the loop for what does not, then switch the allow-list to Enforce and tools from undeclared servers to refused. From then on a name outside the list is refused with a 403 before any model is chosen, and the loop treats that as a bug, as step 3 does.

Enforce is per policy, so a second key under a looser policy can keep a staging agent in dry run while production is enforced.

Step 8: Brokered mode: let Inferbase call a declared server

For a declared server with an HTTPS endpoint and a static token, set the policy's who-calls-the-MCP-tools to Inferbase calls them. Inferbase then fetches the server's tool list, injects it into the request, executes the calls the model makes, and loops until the model has an answer. The request is a plain non-streamed chat completion; the response carries the whole run's tokens, cost and wall time, plus a usage.broker block with the turns, the tools executed, the failures, the servers and why the run stopped. The credential never reaches your service. Streaming is refused rather than served without tools; OAuth sign-in servers are not supported yet.

agent/brokered.py (follows the documentation; not run)
python
# The policy says Inferbase calls the declared server's tools; the request declares none itself.
completion = client.chat.completions.create(
    model="auto",
    messages=[
        {"role": "system", "content": INSTRUCTIONS},
        {"role": "user", "content": task},
    ],
    extra_body={"routing": {"session": run_id}},
)
broker = completion.usage.model_extra["broker"]
print(completion.choices[0].message.content)
print("turns", broker["turns"], "tools", broker["tools_executed"], "failed", broker["tools_failed"], "stopped", broker["stopped"])
# stopped: complete | max_turns | deadline | tool_errors | credential_refused | foreign_tool | disconnected

A run that stops on credential_refused shows on the server's row under Connections until the next successful refresh; a wrong token is visible at once, because saving one fetches the tool list again.

The routing policy

A rule pool of tool-capable models, an allow-list taken from Dry run to Enforce, declared servers only, your client calling the tools unless a server is given a credential, a guardrail on tool results, and caps on every session.

StageSettingValueWhy
PoolHow the pool growsModels that meet a rule: supports tool callingA model that cannot call tools never enters the pool.
RequestTools the app may callDry run, then EnforceRun with Any tool, read what the agent sends under Usage, write the list, dry-run it until the reports are quiet, then enforce.
RequestDeclared MCP servers the tools may come fromThe servers you declaredAllow by server rather than by remembering which tool belongs where.
RequestTools from an undeclared MCP serverServed and recorded, then RefusedDrift is visible before it is refused.
RequestWho calls the MCP toolsYour client, or Inferbase for servers with a credentialBrokering moves execution behind the same policy as model selection, and the credential out of the application.
GuardrailsPrompt injection at the tool-results doorRecord, then RefuseText a tool returns is the usual injection route. Record first to see the verdicts on your tools' output.
SessionMaximum requests and spend per session30 requests, $0.50The bound on one run. The orchestration treats the 429 or 402 as its stop.

Create it under Policies in the portal and attach it to the key the application uses; the steps are in Create a routing policy.

The call

The loop on the Responses shape, which the OpenAI Agents SDK sends: tools declared on the call, function calls answered with their outputs, one session identifier for the run. Execution is a stub here; the build above adds the approval gate, the record and the brokered mode.

python
import json
from openai import OpenAI

client = OpenAI(api_key="inf_your_api_key", base_url="https://api.inferbase.ai/api/v1/inference")

RUN = "order-4217-run-1"  # one session id per run: one record, one set of caps
TOOLS = [
    {
        "type": "function",
        "name": "lookup_order",
        "description": "Fetch an order by id.",
        "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"]},
    },
    {
        "type": "function",
        "name": "update_address",
        "description": "Change the delivery address of an order that has not shipped.",
        "parameters": {
            "type": "object",
            "properties": {"order_id": {"type": "string"}, "address": {"type": "string"}},
            "required": ["order_id", "address"],
        },
    },
]


def execute(name: str, args: dict) -> str:  # your systems; stubs here
    if name == "lookup_order":
        return json.dumps({"order_id": args["order_id"], "status": "packed", "shipped": False})
    if name == "update_address":
        return json.dumps({"ok": True, "order_id": args["order_id"], "address": args["address"]})
    return json.dumps({"error": f"unknown tool {name}"})


items = [{"role": "user", "content": "Order 4217: the customer asks for the delivery address to be changed to 12 Rue des Lilas, Lyon. Do it if the order has not shipped, and tell me what you did."}]
for _ in range(6):
    response = client.responses.create(
        model="auto",
        instructions="You are an operations agent. Use the tools; never guess an order's state.",
        input=items,
        tools=TOOLS,
        max_output_tokens=400,
        extra_headers={"X-Inferbase-Session": RUN},
    )
    calls = [item for item in response.output if item.type == "function_call"]
    if not calls:
        print(response.model, "->", response.output_text)
        break
    for call in calls:
        items.append(call)
        items.append({"type": "function_call_output", "call_id": call.call_id, "output": execute(call.name, json.loads(call.arguments))})

Both snippets were run against api.inferbase.ai on 2026-10-13: the model looked the order up, found it unshipped, changed the address through the second tool, and reported what it did, on MiniMax M3 in 7 to 10 seconds for the whole run.

Open in PlaygroundAPI reference

Security and privacy

The controls are about names, credentials, consequences and bounds, and about where each one is enforced.

Names are enforced by the gateway
An enforced allow-list refuses a tool outside it before a model is chosen, for every key under the policy. The loop cannot widen it; a deploy cannot remove it.
Provenance is declared
Declared servers give a merged tool list its origin back. A tool from no declared server is drift: served and recorded while you learn, refused once you know.
Credentials sit in one place
In client mode your service holds them and the API key. In brokered mode the declared server's token lives with Inferbase, sent as a bearer on every call to that server, and your service holds only the API key. Give each server a token with the least scope that serves its tools.
Consequences are approved by a person
Writes wait in the approvals table for a decision from your channel. A declined write returns to the model as text, never as a retry.
Tool results are inspected
The injection check at the tool-results door reads text a tool returned before the model does. Attacks arrive there more often than in prompts.
Runs are bounded
Session caps on requests and spend; for brokered runs also a turn ceiling, a deadline and a limit on tool failures. A run that stops says why.
Everything is recorded
Every call's request id, every tool declared and called, every approval, and for brokered runs the usage.broker block. What the agent did is answerable afterwards.

Evaluation

A task set where each task has an expected outcome and an expected tool sequence. Run them through the service with approvals auto-granted in the test harness, and score four things: did the outcome match, were any tools called that should not have been, how many turns, what did it cost. Then run the same set pinned to one model.

Outcome match
The state of the order system after the run against the expected state: address changed, or not changed because it had shipped. The only success measure that matters.
Unwanted tools
Calls to tools the task did not warrant, from the record. During dry run, also the gateway's reports of tools outside the list.
Turns and cost
From the record and the receipts. A good run reads once, writes once, answers once.
Declines
Include tasks the approver declines in the harness and check the model reports the decline rather than retrying or pretending.
Auto against pinned
The same set with the model pinned. Compare outcome match and cost; tool-use quality differs between models more than chat quality does.
eval/run.py
python
for case in tasks:  # {"task": ..., "expect_tools": ["lookup_order", "update_address"], "expect_state": {...}, "approve": True}
    reset_orders(case["fixture"])
    approvals = AutoApprovals(approve=case["approve"])
    run_id = f"eval-{case['id']}"
    answer = run(case["task"], run_id, store, approvals)
    called = [t for call in store.calls(run_id) for t in call.tools]
    ok_state = orders.snapshot() == case["expect_state"]
    unwanted = [t for t in called if t not in case["expect_tools"]]
    print(case["id"], "state ok" if ok_state else "STATE WRONG", "unwanted:", unwanted, "turns:", len(store.calls(run_id)))

Operations

Three views, two of them the gateway's: Agent runs, Usage › Tools, and your approvals table.

Agent runs
Under Usage, requests grouped by session: each call's model, task and cost, the tools declared and called. Your run store should agree; the request id reads back any decision.
Tools first seen
A tool name a project sends for the first time raises an event. That is the moment to decide whether it joins the list.
Dry-run reports
Tools served outside a dry-run list, with the key. Quiet reports for a week mean the list is complete.
Server rows
Under Connections, a declared server shows its last refresh and any credential refusal. Saving or replacing a token fetches the tool list again, so a wrong token shows at once.
Why runs stopped
For brokered runs, usage.broker.stopped by value over a week: complete should dominate; max_turns and tool_errors point at prompts or tools; credential_refused points at a token.
Approval latency
From the approvals table: time from request to decision. Long waits are an argument for a tighter list of what needs approval, not for approving less carefully.

Scaling and limits

Runs are independent and tool calls are the slow part. The limits are the key, your systems, and the brokered run's bounds.

Real time, measured
An operations chat where an action is being taken, not a sub-second assistant. The verified run of three model calls and two tool calls took 7 to 10 s end to end on 2026-10-13 with stubbed tools; real tools add their own latency, and a brokered run adds the server round trips. Each call waits on the provider's first token, 1.5 to 3.5 s on 2026-10-14. Show progress per step, and stream the final message from your service.
Concurrent runs
A queue in front of the service; each run is a handful of requests. The key's rate limit is the ceiling; raise it before launch.
Tool latency
In client mode, measure it yourself. In brokered mode Inferbase measures it per call and records it on the run.
Many servers
Declare each; allow by server in the policy. In brokered mode a run may span several servers, and usage.broker lists which offered tools.
Long transcripts
The Responses endpoint keeps no state, so each call carries the whole transcript. Summarise old tool outputs in the items you send when a run grows long.
Streaming
A brokered run cannot stream, because it is several model calls with execution between them. Stream the final message to the operator from your service instead.
Bounds
Session caps, a turn ceiling, a deadline and a failure limit all end a run; set each to the shape of a reasonable run and read the stop reasons to tune them.

Troubleshooting

What goes wrong when this is built, what it means, and what to do. Each one was met while building or running it.

SymptomCauseFix
403 naming a toolThe allow-list is enforced and the request declared a tool outside it.Add the tool if it belongs, fix the loop if it does not. The record names the tool and the key.
A brokered run stopped with foreign_toolThe model called a tool no declared server offers.Declare the server that offers it, or tighten the prompt; the stop is the policy refusing drift.
A brokered run stopped with credential_refusedThe declared server rejected its token.Replace the token under Connections; saving it fetches the tool list again, which confirms the fix.
400: previous_response_id is refusedThe client assumed server-side conversation state.Send the whole transcript in input on every call, as the Agents SDK does by default.
400: a hosted tool type is refusedThe request declared web_search, file_search, code_interpreter or mcp as OpenAI hosted tools.Inferbase routes models; it does not run OpenAI's hosted tools. Use function tools, or declare an MCP server.
A streaming request on a brokered policy is refusedA brokered run is several calls with tool execution between them.Send it non-streamed and stream the final message from your service.
The loop retries a write the approver declinedThe decline was returned as an error rather than as a tool output.Return a declined output as text, as the gate does; the model explains instead of retrying.
Runs end on 429 or 402 too oftenThe session caps are below the length of a normal run, or a loop is not converging.Read the stopped runs; raise the cap if they were normal, fix the prompt or the tools if they were loops.

Extending

The loop is small, and the policy carries most of the behaviour. Each extension is a tool, a server, or a channel.

More systems
A tool per verb, a read or write flag, a line in the executor. The allow-list grows by one name per deploy, reviewed in dry run first.
Enterprise sources through brokered MCP
Declare the vendor's remote server with a scoped token and let Inferbase call it. The service never holds the token and the allow-list applies at execution.
The OpenAI Agents SDK
Point it at the Responses endpoint; the loop in step 3 is what the SDK does internally. The session header and the approval gate attach through its hooks.
Multi-agent
A planner that hands sub-tasks to specialised loops, each with its own session and its own policy key. Governance stays per key.
Approvals in Slack or a ticket
The approvals table is the queue; a bot that posts a request and writes back the decision is the channel. The run waits either way.
Your own keys
Build the pool from a provider key or cloud account. The loop does not change; the policy decides whose credential serves each call.

What was measured

What one turn and one run cost in time on the public API. Tool execution was a stub, so real runs add your systems' latency.

MeasurementValue
The verified two-tool run, three model calls, non-streamed, 2026-10-137 to 10 s
Streamed answer, first token, latency objective, a short prompt (3 runs)1.5 to 3.5 s
Time to decide (classify and rank), live traffic, 2026-10-13p50 18 to 31 ms

Measured from our own infrastructure in India against api.inferbase.ai on 2026-10-14, three runs each, client-side wall time including the network hop (about 0.25 s each way), thinking off. Every response returns its own figures.

Further reading

Frequently asked questions

Put intelligence in the middle.

One gateway in front of every model, with your policies applied and every decision on record. Start on the free plan with 5,000 requests a month, no card required.