How to design tool contracts for AI agents

How to design agent tools: clear descriptions, validated parameters, permissions, readable errors, and retries without duplicating actions.

person at a laptop with a diagram of model proposal, validation, and agent tool execution

An agent tool is not just a function that calls your product API. It is a contract written for the model: name, description, which data it may send, and what shape of response it will get back. The model decides whether to call the tool and with which arguments; your product code validates, checks permissions, runs the real action, and returns a result the model can read on the next step. If the contract is vague, the model fills fields wrong. If execution has no rules, one failure becomes three identical calls.

This article answers how to define that tool so the model uses it well, the system runs it with the right permissions, and errors do not break the whole task.

If you want the agent layer map (model, actions, memory, permissions, records), see the model is not the agent. This article goes one level deeper: how to write the contract for one concrete tool before you expose it in production.

Provider docs call this function calling or tool use: the model does not hit your API directly. It returns a JSON proposal (which tool and which parameters), and server code decides whether to run it, with which credentials, and what to send back to the model.

That definition (name, description, schema, response shape) is separate from how you run the action afterward. When a tool is available, the model receives a contract definition; the harness decides when to expose it (filter, version, group, or discover tools dynamically). Execution might be an internal HTTP call, a command in a bounded environment, or an MCP server the agent client discovers: that is what we compare in MCP vs CLI. Changing the surface without tightening the contract only moves the problem.

Why the contract matters more than the function

In a demo you often show one concrete action: a tool named create_refund that takes an order_id and, in the meeting, returns "refund created" once. In the room you only see the scenario you rehearsed, and it is easy to assume the agent will refund correctly in every real situation.

In production the model does not see your function in source code. It sees the tool definition as text, on every turn where that tool is available. If the description only says "handles orders" and the schema allows ten optional fields, the model will try combinations the API rejects: amounts without currency, IDs in the wrong format, or mixed read and write actions.

The contract is what the model reads. The server function is what actually changes data. When the team confuses the two, you usually see either weeks of prompt rewriting when the problem is the schema, or a hardened prompt when the problem is API errors the model cannot interpret.

Anthropic tool use documents clear names and tight schemas; in Building effective agents, on agents that solve issues on SWE-bench, they report spending more time tuning tool formats (paths, parameters, outputs) than system instructions. OpenAI function calling describes the same flow (description, typed parameters, server-side validation), and structured outputs stress sticking to the schema. What SaaS teams often lack is not a link to the provider, but one internal map: schema, permissions, readable errors, and retries without duplicating business side effects.

What a tool contract should include

Four pieces the model and execution code must share: name, description (what it is for), input parameters, and output shape (success or error).

Names and descriptions the model can use

The name is a stable identifier: get_order, not fetchOrderData_v2. The model will cite it in the call proposal; if it changes every week, traces and regression tests stop lining up.

The description should say when to use the tool and when not to. "Returns order status by ID" beats "order tool". One line such as "do not use this to create refunds; use create_refund" cuts cross-calls more than a long system prompt paragraph.

With many tools, the contract also includes whether this tool is visible for this task. Loading fifty definitions on the first message fills context and worsens choice. In the agent harness (the program that runs the model-tools-permissions loop) you often have discovery or grouping by use case: a tool may exist in the system but not be offered to the model until the task needs it.

Parameters, types, and required fields

The schema (often JSON Schema on OpenAI and Anthropic APIs) fixes types, required fields, and sometimes enums. order_id as a required string stops the model from sending a nested object your API cannot parse.

Rules that work in SaaS:

  • One purpose per tool. Mixing "search" and "delete" in one function forces the model to pick a mode with a flag; two separate tools usually fail less.

  • Explicit business IDs (tenant_id, order_id) in the schema when the action is per customer account, instead of assuming "it is already in context".

  • Numeric limits in the schema (limit max 20) aligned with what the API rejects, not what the model thinks is reasonable.

The provider may reject the proposal before execution; your server must validate again before the business API.

What the tool returns (and what it does not)

Output goes back to the model in the next tool message as trimmed text or JSON: fields for the next step and, on failure, a stable code plus a short message. Do not forward raw API payloads, secrets, tokens, or another customer's data. Identity and permissions apply to tool output too.

Permissions: the model proposes, the system runs

The model only emits a proposal: tool name and arguments. What runs is your service, with the agent identity, tenant, and user permissions. If you skip that layer because "the model knows it should not", you do not have an execution contract, only hope.

Minimum flow:

  1. The model proposes create_refund with { "order_id": "…", "amount": 50 }.

  2. The runtime checks the tool exists, arguments validate against the schema, and the agent may perform that action on that account.

  3. Only then call the business API, often with scoped service credentials, not the admin's broad session.

The same tool name can run differently by role: read without friction, write behind a threshold or approval queue. That should not live only in the model-visible description; it belongs in harness policy.

Errors the model can fix

If the API returns HTML or a generic message, the model has nothing to fix. If the tool returns small JSON with a stable code, the harness and the model can react differently by type:

Type

Example codes

What to do

Technical or transient

timeout, upstream_unavailable

Bounded retry in the harness; the model should not loop the same call.

Validation

invalid_argument

The model can fix parameters (field, format, expected value).

Permissions

permission_denied, tool_not_allowed

Stop; different role, tool, or human approval.

Business

not_found, already_exists, conflict

Not a network glitch: read state or change strategy instead of repeating the same mutation.

Avoid generic "error processing request". In traceability those loops can look like several HTTP successes and a silent business failure.

Retries, timeouts, and limits

A short API timeout is not permission for the model to fire the same tool five times in a row. After a timeout you do not know whether the action ran: before repeating a mutation, read state (for example whether the refund already exists) or resend the same idempotency key your business API understands. Without that rule, a duplicate refund is an incident, not a networking detail.

Practical policy:

  • Automatic retries only on transient errors, with a cap (for example 2) and an idempotency key on side-effect actions.

  • No automatic retry on validation, permission, or business outcomes from the table above.

  • Cap calls to the same tool per task to stop loops ("search again" ten times).

The description can guide the model ("if not_found, ask for another id"); the harness must enforce caps even when the model ignores it.

Minimal code example

Definition the model sees (common shape on schema-compatible APIs):

{
  "name": "get_order",
  "description": "Gets order status by ID for the current account. Does not create or modify orders. Use only when you already have order_id.",
  "parameters": {
    "type": "object",
    "properties": {
      "order_id": {
        "type": "string",
        "description": "Order identifier in the billing system."
      }
    },
    "required": ["order_id"],
    "additionalProperties": false
  }
}
{
  "name": "get_order",
  "description": "Gets order status by ID for the current account. Does not create or modify orders. Use only when you already have order_id.",
  "parameters": {
    "type": "object",
    "properties": {
      "order_id": {
        "type": "string",
        "description": "Order identifier in the billing system."
      }
    },
    "required": ["order_id"],
    "additionalProperties": false
  }
}
{
  "name": "get_order",
  "description": "Gets order status by ID for the current account. Does not create or modify orders. Use only when you already have order_id.",
  "parameters": {
    "type": "object",
    "properties": {
      "order_id": {
        "type": "string",
        "description": "Order identifier in the billing system."
      }
    },
    "required": ["order_id"],
    "additionalProperties": false
  }
}

additionalProperties: false cuts invented fields from the model; check that your provider accepts it in the schema (for example strict mode in OpenAI function calling or the rules for your Anthropic API version).

Server execution (pattern, not a specific library):

async function runToolCall(proposal, ctx) {
  if (proposal.name !== "get_order")
    return toolError("tool_not_allowed", "This tool is not enabled for this task");
  const parsed = validateGetOrder(proposal.arguments);
  if (!parsed.ok) return toolError("invalid_argument", parsed.message);
  if (!ctx.can("orders:read", ctx.tenantId))
    return toolError("permission_denied", "No permission to read orders");
  const order = await billingApi.getOrder(ctx.tenantId, parsed.value.order_id);
  if (!order) return toolError("not_found", "Order not found on this account");
  return toolSuccess({ order_id: order.id, status: order.status, total: order.total });
}
async function runToolCall(proposal, ctx) {
  if (proposal.name !== "get_order")
    return toolError("tool_not_allowed", "This tool is not enabled for this task");
  const parsed = validateGetOrder(proposal.arguments);
  if (!parsed.ok) return toolError("invalid_argument", parsed.message);
  if (!ctx.can("orders:read", ctx.tenantId))
    return toolError("permission_denied", "No permission to read orders");
  const order = await billingApi.getOrder(ctx.tenantId, parsed.value.order_id);
  if (!order) return toolError("not_found", "Order not found on this account");
  return toolSuccess({ order_id: order.id, status: order.status, total: order.total });
}
async function runToolCall(proposal, ctx) {
  if (proposal.name !== "get_order")
    return toolError("tool_not_allowed", "This tool is not enabled for this task");
  const parsed = validateGetOrder(proposal.arguments);
  if (!parsed.ok) return toolError("invalid_argument", parsed.message);
  if (!ctx.can("orders:read", ctx.tenantId))
    return toolError("permission_denied", "No permission to read orders");
  const order = await billingApi.getOrder(ctx.tenantId, parsed.value.order_id);
  if (!order) return toolError("not_found", "Order not found on this account");
  return toolSuccess({ order_id: order.id, status: order.status, total: order.total });
}

The model proposal never calls billingApi directly. That middleware is the execution contract.

Checklist before exposing a tool in production

Question

If the answer is no

Do the name and description say when to use the tool and when not to?

Rewrite the contract before touching the prompt.

Do required parameters and types match the real API?

Fix schema or API; do not trust the model to "fill it in well".

Is output to the model trimmed and free of sensitive data?

Redesign the tool response.

Does the runtime validate permissions and tenant before running?

It is not a production tool; it is a demo with broad credentials.

Are errors returned to the model stable, coded, and actionable?

The model will not recover on its own.

Is there a retry cap and a rule for side-effect actions?

Plan for duplicate incidents.

Can you reconstruct in one trace: proposal, validation, API call, and result?

Review agent observability before scaling traffic.

When the checklist holds, the tool can join the agent catalog with the same rigor as a public HTTP route: versioning, regression tests with real model proposals, and review when the business API changes.

If the next step is many tools at once, prefer a small catalog per use case and narrow contracts before a "wrapper" around your entire public API. The model does not need the full map; it needs the right action with minimal data and a system that runs with judgment.

Alberto Iglesias

CEO

Turn your SaaS AI-Native

Get a free trial just by signing up


Ready to turn your platform AI-Native?

Centralize agents, tools, and flows in one platform and start scaling with less friction.

Build and run agents without friction

Connect your current infrastructure

  • Devic AI

  • Devic AI

  • Devic AI

Ready to turn your platform AI-Native?

Centralize agents, tools, and flows in one platform and start scaling with less friction.

Build and run agents without friction

Connect your current infrastructure

  • Devic AI

  • Devic AI

  • Devic AI