AI Agent Engineering Series

An AI agent uses tools to read and change systems outside the model. With function calling, the model only proposes a call. Application code decides if the call runs, for which user, with which limits, and what the model sees back. This part explains the tool contract and how to wrap a REST API as task-level tools. It also explains the write path that makes a refund safe to retry.

Function calling is a proposal

Function calling (Anthropic calls it tool use) is an API feature. You send the model a list of tool definitions. The model can answer with a structured request to call one of them. The OpenAI function calling guide describes the loop in five steps. You send tools and receive a call with a call_id. You run the function and return the output with that call_id. The model then gives a final answer or more calls. Anthropic's tool call guide uses tool_use and tool_result blocks for the same loop.

The model never runs the function. It produces JSON that names a tool and gives arguments. Every step with authority happens in your code. This is the deterministic boundary from Part 1, applied to the outside world. The run loop from Part 2 stays the same. The tool layer is the executor that the loop calls.

Both providers can constrain arguments to your schema. Anthropic's strict tool use guarantees that the input follows input_schema and that the tool name is valid. OpenAI's strict mode requires additionalProperties: false and every field in required. Turn strict mode on, and keep server-side validation too. A schema-valid argument can still name another customer's invoice, because a schema cannot check ownership.

The tool call crosses a trust boundaryThe model proposes a tool name and JSON arguments. The proposal crosses a trust boundary into application code. Code looks up the tool and checks that the principal can see it, validates the arguments, authorizes the principal against the record, runs the call with limits through the adapter, and shapes the result. The principal comes from the session. Only the shaped result goes back to the model. Model proposes name + JSON arguments trust boundary shaped result Application code 1 Look up the tool hidden or missing โ†’ unknown_tool 2 Validate arguments strict schema, ID patterns, limits 3 Authorize principal vs the record it names 4 Run with limits deadline, retries, byte cap 5 Shape the result allow-listed fields, size limit Session principal Adapter to the REST API Any failed check returns an error code and stops the call.
FIG 01The model proposes, and every check that carries authority runs in code on the far side of the boundary.

The checks run in this order for three reasons. A tool that the principal cannot see returns unknown_tool, the same answer as a tool that does not exist. The model then cannot probe for tools it is not allowed to use. Validation runs before authorization because it is cheap and needs no upstream call. The deadline check runs before dispatch, and the handler gets only the time that remains in the run budget from Part 2. A remote write can still finish after a timeout, which is why writes need the receipts below.

The tool contract has five parts

A tool is a contract with five parts. The table shows who reads each part and what enforces it. The authorization rule lives only in code, because a description never grants permission.

Part Read by Rule Enforced by
Name Model service_object_verb, unique, 1-64 characters Registration check
Description Model What it does, when to use it, what it does not return Review and selection evals
Input schema Model and code Strict, additionalProperties: false, patterns on IDs, length caps Validator before the handler
Output shape Code Allow-list of fields, size cap Projection after the handler
Authorization Code only Identity from the session, scope per tool Registry and handler

Anthropic's tool definition guide calls detailed descriptions "by far the most important factor in tool performance". It asks for at least 3-4 sentences per tool and recommends service prefixes such as github_list_prs. Anthropic's engineering post on writing tools for agents adds two rules. Return only high-signal fields, and write error responses that tell the agent what to do next. The OpenAI guide suggests fewer than 20 functions at the start of a turn, and it calls that a soft limit.

Keep money as integers in minor units (12900 means 129.00 USD) when the source API uses that form. Better still, keep the amount out of the input schema. A refund tool can take an invoice ID and a reason from an enum, and code computes the amount from the invoice.

Identity comes from the session

The principal is the user or service that the run acts for. It has a user ID, a tenant ID, and a set of scopes. Your session layer creates it after authentication, and it travels in the call context. It never appears as a tool argument.

If a schema has an account_id or customer_id field, a user can type another customer's ID and the model can pass it on. With identity in the context, each handler checks the requested record against the principal. A record in another tenant returns not_found, the same answer as a missing record, so the tool cannot reveal which IDs exist. A tool that needs a scope, such as billing:refund, is absent from the tool list for a principal without that scope.

Later parts use the same principal. Part 5 keys memory by its user ID. Part 6 filters documents by its tenant and scopes. Part 8 checks policy against it. For the case against long-lived keys in the agent, see agent identity management.

Wrap a REST API as task-level tools

A REST API has two layers of decisions. Transport decisions are the host, the token, the path, timeouts, retries, and redirects. Task decisions are which record to read, which fields matter, and what a refund means. Put transport decisions in an adapter and task decisions in a small set of tools on top of it. The model sees only the tools.

Task-level tools and an adapter in front of a REST APIFour layers. The model sees tool names, descriptions, schemas, and allow-listed results. Task-level tools own the tenant check and compute amounts from the record. The REST adapter owns the base URL, the token, timeouts, retries, the redirect policy, the size cap, and error codes. The billing API stores refunds by idempotency key. On the right, a generic HTTP tool skips both green layers. The model picks the URL, headers, and body, and one injected result can send data to any host. Model Sees tool names, descriptions, schemas, and allow-listed results only. Task-level tools billing_invoice_get ยท billing_refund_create Own tenant checks, compute amounts. REST adapter Owns base URL, token, timeouts, retries, redirect policy, size cap, error codes. Billing API System of record. Keys refunds by intent. HTTP tool generic Model picks the URL, headers, and body. One injected result can send data to any host. Skips both green layers.
FIG 02Each layer owns a set of decisions, and a generic HTTP tool moves all of them to the model.

The adapter owns everything the model must not decide. It escapes every path parameter, so an ID such as ../admin cannot change the route. It refuses redirects, so a 3xx to another host never receives the bearer token. It reads at most a fixed number of bytes, so a large response becomes an error instead of a full context window. Each attempt uses the smaller of its own timeout and the time left in the run.

Design What the model decides Main risk Use when
Generic HTTP tool Host, path, headers, body Exfiltration, wrong routes, token misuse Almost never in production
One tool per endpoint Endpoint order, pagination, field meaning Many near-duplicate tools, raw payloads The API is small and already task-shaped
Task-level tools Which record and which action A missing task needs a new tool Default for business systems
Prefetch in code Nothing None from the model Every request needs the same lookup

Handle pagination inside the adapter when the task needs all pages, and set a maximum page count. Expose a cursor argument when the model must decide whether another page is useful. Either way, the tool returns a bounded result. The post on budgeting tool output gives sizes and enforcement patterns.

Shape every result with an allow-list

The output schema lists the fields that the next decision needs. Code drops every other field before the result goes to the model. A billing API can return card digits, a billing address, internal notes, and processor references. The invoice tool returns the total, the currency, and a status and amount for each payment. A new upstream field stays out of the model's context until someone adds it to the allow-list on purpose.

A size limit backs up the allow-list. If a shaped result is still over the limit, the tool returns result_too_large, and the model can narrow the request. Truncating silently is worse, because the model then reasons over a partial record without knowing it.

Tool results are untrusted text. Anthropic's tool call guide warns that they are a path for indirect prompt injection. Shaping cuts the volume of attacker-controlled text that reaches the model. Policy checks in Part 8 handle the rest.

Return errors the model can act on

Each failure maps to a stable code and a retryable flag for the model. A detail string such as HTTP 404 on GET /invoices/{id} goes to the operator trace only. The model gets enough to choose its next step and nothing about your internal routes.

HTTP result Code for the model Retry in code
400, 422 invalid_input No. The model can correct the arguments.
401, 403 denied No. The run stops and does not try variations.
404 not_found No
409 conflict No. Read the current state again.
429 rate_limited Yes, with Retry-After, inside the deadline
502, 503, 504 upstream Yes, for GET and for keyed writes
Timeout on GET timeout Yes, then report
Timeout on POST ambiguous_write Only with the same key

The last row is the hard case. After a timeout on a POST, the client cannot know if the write happened. The next two sections make that case safe.

Writes take six stages

A read can leak data. A write changes a system, and a retry can change it twice. So a write tool has a second function, prepare, that computes the exact effect without changing anything. The model's call to a write tool produces a preview. The commit is a separate operation that only the harness can call, after approval.

The write path from request to receiptSix stages. The model requests a write with an invoice ID and a reason. Code prepares the exact effect. The registry stores a preview with the effect, a fingerprint, an idempotency key, and an expiry, and the run pauses in needs_approval. A person or policy approves the fingerprint. If the effect changed, the commit fails with approval_mismatch. The commit sends the write once with the Idempotency-Key header. The source system returns a receipt, stored under the key. A retry after a lost response returns the same receipt. 1 Request model sends invoice ID + reason 2 Prepare code computes the exact effect 3 Preview effect, fingerprint, key, expiry 4 Approve approval binds to the fingerprint 5 Commit one send with Idempotency-Key 6 Receipt proof from the source system Run pauses needs_approval Effect changed โ†’ approval_mismatch Lost response retry โ†’ same receipt
FIG 03The person approves a fingerprint of the effect that code computed, so the approved write is the one that runs.

The preview holds the effect that code computed. It never holds text that the model wrote. Its fingerprint is a hash of the tool, the effect, the principal, and the idempotency key. If any part of the effect changes between preview and commit, the fingerprint changes, and the old approval no longer matches. The commit takes only a preview ID and a fingerprint, so nobody can slip new arguments in at the last step.

The commit checks run in a fixed order:

commit(preview_id, approved_fingerprint, session):
  p = previews[preview_id]                  else fail not_found
  if p.principal != session.principal       fail denied
  if approved_fingerprint != p.fingerprint  fail approval_mismatch
  if receipts has p.key                     return receipts[p.key]  (replayed)
  if now > p.expires_at                     fail preview_expired
  data = send(p.effect, Idempotency-Key = p.key)
  receipts[p.key] = data
  return data

The receipt check comes before the expiry check. A retry after expiry then gets the original receipt instead of an error that hides a completed write. In production the receipt store is a durable table with a unique constraint on the key. Write the receipt in the same transaction as the record that marks the step done.

The final answer to the user cites the receipt ID. Only a receipt from the source system shows that the write happened, so Part 7 grades writes against environment state instead of the model's message.

Idempotency keys name the business intent

RFC 9110, section 9.2.2 defines PUT, DELETE, and safe methods such as GET as idempotent. For other methods, a client "SHOULD NOT automatically retry" unless it knows that the request is idempotent. A POST is safe to retry only when the server enforces idempotency for that key. The header alone does not make a request idempotent. The header is specified in an IETF Internet-Draft, The Idempotency-Key HTTP Header Field, which is not yet an RFC. Stripe's idempotent requests use the same header. Stripe compares the parameters of a reused key and returns an error when they differ. It can prune keys after 24 hours, so a retry after that window is a new request.

A lost response with and without an idempotency keyThe agent sends a refund POST for 129.00 USD. The server creates refund re_1, but the response is lost and the client sees a timeout. If the client retries with no key or a new random key, the server creates a second refund, re_2, and 258.00 USD goes out. If the client retries with the same intent key, the server finds the key and returns the stored re_1, so one refund exists. Agent sends refund POST, 129.00 USD Server creates refund re_1 Response lost. Client sees a timeout. Retry no key or a new random key Retry same intent key Two refunds re_1 + re_2, 258.00 USD out One refund server returns stored re_1
FIG 04After a timeout the client cannot know if the write happened, and only a stable key makes the retry safe.

The key must identify one intended business action. The table compares four ways to derive it.

Key source Retry after a lost response Problem
New random value per attempt Creates a second write Every retry looks new to the server
Hash of all model arguments Usually safe The model can reword a free-text field on the next call
Hash of run ID, tool, and intent fields Safe Two separate runs get two keys
Business ID from the source (for example, payment ID) Safe across runs Needs one stable ID per action

The default in this series is the third row. The intent fields are the arguments that name one business action, such as the invoice ID and the reason. The model has no part in choosing the key. The OpenNash post on idempotency keys and optimistic locking covers the same design for parallel agents and stale reads.

A run-scoped key does not stop a business duplicate. Two runs that ask for the same refund get different keys. Check for an existing refund in prepare for early feedback. Two runs can both pass that check, so the system of record must also enforce one refund per payment when it commits. The source system must also handle two requests with the same key that arrive at the same time. The IETF draft suggests 409 Conflict for a request whose key is still in progress.

If the upstream API has no idempotency support, a wrapper service must reconcile with the upstream records or control the write atomically. Without either, an unknown outcome goes to an operator. Agent loops, job retries, and process restarts all repeat calls.

Match approval to the risk of the write

Some writes do not need a person. A low-risk, reversible write, such as an internal note, can use an automatic policy approval. A high-cost or irreversible write needs a human. The decision belongs to the action, so the same agent can have both kinds of tools. The post on approval gates and exception routing gives a risk-by-reversibility grid for that choice. Part 8 binds each approval to a hash of the exact call and consumes it once.

A draft is often enough. A tool that writes a reply for a person to send carries less risk than a tool that sends it. Start with drafts, measure how often people change them, and promote the tool when the data supports it.

Programmatic Tool Calling

Programmatic Tool Calling lets the model write a short program that calls your tools inside a sandbox. The program filters the results and returns only the final value to the model. Anthropic's programmatic tool calling docs enable it per tool with an allowed_callers field, and intermediate results stay out of the model's context window. OpenAI's GPT-5.6 guide offers the same idea with a programmatic_tool_calling tool.

Each tool call from the program still reaches your application as a normal tool call. The registry still validates, authorizes, and shapes every call, and the program cannot reach the commit. The sandbox decides the order of calls, and your code still decides which calls run. Treat allowed_callers as routing guidance, not as access control. The registry must authorize every call the same way, whichever caller made it. When an MCP server runs this pattern on its side, it is called code mode. MCP search and execute explains that design.

OpenAI recommends it for "bounded, tool-heavy workflows that do not require fresh model judgment between each step." It recommends direct calls in five cases. Use them when one call is enough or the results are already small. Also use them when each result can change the next decision, when an action needs approval, or when the output must keep citations. Anthropic lists strict sequential workflows and small numbers of small calls as weak fits. It also notes that tools with strict: true are not supported with programmatic calling, which is one more reason to validate on the server.

Task shape Use
Look up 20 invoices and return the ones with duplicate payments Programmatic calling on the read tool
Read one invoice, then decide what to do Direct call
Any write, refund, or message Direct call with preview and approval
Each result changes which tool to call next Direct call
Output must keep a citation for each source Direct call

Mark only read tools as callable from code. Keep write tools direct-only, so every write passes through a preview that a person or policy can see.

Test tools apart from the model

Most tool bugs are ordinary bugs. Tests against a fake API find a missing tenant check, a leaked field, or a retry that doubles a write. They also find an ID such as ../admin that changes the path. No model is needed for them. Test model behavior separately. Check that the model calls a tool when needed, skips it when not, picks the right one, and stops after denied. Part 7 turns those checks into graders.

When not to add a tool

Every tool adds a choice for the model and a contract for you to maintain. Skip the tool when code can do the work without model judgment. If every request needs the same invoice lookup, run it before the model call and put the result in context. Merge near-duplicate tools into one tool with an enum argument. Show the model only the tools for the current step and principal.

Do not wrap an API whose writes are not idempotent and whose failures you cannot classify. Fix the API or put a service in front of it first.

How OpenNash helps

OpenNash forward-deployed engineers work with Fortune 2000 teams to connect agents to billing, CRM, and support systems. We map the API operations the agent needs, mark each as read or write, and define the task-level tool contracts. Every write gets a preview, an approval rule, and an idempotent receipt before it reaches production. Your team owns the contracts and the tests at the end. Book a call with an OpenNash forward-deployed engineer to review the systems your agent must use and find the integration with the fastest return.

Previous: AI Agents Part 2: Harness and CLI

Next: AI Agents Part 4: MCP explains when to expose these tools through an MCP server and when to call them directly.