AI Agent Engineering From Scratch

Tools let an agent read data and request actions in other systems.

A tool can turn a model error into a real action. The error can expose data, duplicate a payment, delete a file, or send the wrong message.

Use this rule: the model can request a tool call. Application code decides whether to run it.

A tool is a contract

A tool has five important parts:

  • a stable name.
  • a description of when it should be used.
  • an input schema.
  • a structured result.
  • application rules that control who can run the tool and under which conditions.

For example:

{
  "name": "orders_get",
  "description": "Read the current status of one order the caller is allowed to view.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "order_id": { "type": "string" }
    },
    "required": ["order_id"],
    "additionalProperties": false
  }
}

The schema limits the arguments that the model can provide. It also lets the application validate each argument before it runs the tool.

The description helps the model choose. It does not grant permission. A tool called read_only_search can still be dangerous if its implementation reads private data or follows untrusted URLs.

Write names and descriptions for the model. Enforce authorization and isolation in application code.

The contract should also describe output. A stable result lets the next model call and the trace use the same fields.

{
  "status": "ok",
  "order": {
    "order_id": "ord_456",
    "currency": "USD",
    "total_minor": 12900,
    "payment_count": 2
  }
}

Use integers in minor currency units when the source API uses that representation. Do not ask a model to calculate financial amounts from formatted strings.

Version a tool contract when a change can break callers. Adding an optional field is different from renaming a required argument.

Function calling is a request

Model providers use different terms: function calling, tool use, actions, or response items. The application flow is the same:

  1. Send the model the tools available for this turn.
  2. Receive a structured request.
  3. Validate the tool name and arguments.
  4. Identify the authenticated user or service that started the request.
  5. Check whether that identity can access the target record.
  6. Run the tool with limits on time, network access, and returned data.
  7. Record the result.
  8. Give the observation back to the model or stop.

Function calling returns structured data. Application code decides whether to execute the requested function.

Application code can reject an unknown tool, invalid input, cross-account access, an expired approval, or a request that exceeds its limits.

Implement a dispatcher, not a bag of functions

A dispatcher gives every tool call the same validation, authorization, timeout, logging, and error behavior.

async function dispatchTool(
  call: ToolCall,
  session: Session,
  runSignal: AbortSignal
) {
  const tool = registry.get(call.name);
  if (!tool) return failure("unknown_tool");

  const args = tool.inputSchema.parse(call.arguments);
  await tool.authorize({ principal: session.principal, args });

  const signal = AbortSignal.any([
    runSignal,
    AbortSignal.timeout(tool.timeoutMs)
  ]);

  const value = await tool.execute({
    args,
    principal: session.principal,
    signal
  });
  return tool.outputSchema.parse(value);
}

The authenticated principal enters from the session. It never comes from call.arguments.

The registry stores server-side policy with the implementation. The model receives only the safe contract fields needed to choose the tool.

Wrap the dispatcher with trace events. Record the call ID, tool version, result status, latency, and returned data reference.

Expose task-level tools

A general HTTP tool lets the model send requests to many endpoints:

http_request(method, url, headers, body)

It also gives the model responsibility for URLs, credentials, headers, pagination, response formats, and network destinations. Most applications do not need that risk.

Expose the job instead:

orders_get(order_id)
invoices_list(account_id, cursor)
policy_search(query)
refund_preview(invoice_id, reason)

The adapter underneath can still call REST, GraphQL, a database, or a legacy service. The model does not need to know.

The application code behind each tool should handle:

  • credentials.
  • URL construction.
  • timeouts and cancellation.
  • pagination.
  • response validation.
  • retry policy.
  • error normalization.
  • data minimization.
  • telemetry.

The model does not need to see URLs, headers, pagination data, or raw API responses.

The application can enforce the API rules in one place.

Task-level tools also protect the application from upstream API changes. The adapter can translate a stable agent contract into a new vendor request.

For example, invoices_list(account_id, cursor) can hide whether the source uses page numbers, opaque cursors, or GraphQL connections.

Keep pagination inside the adapter when the task needs all pages. Expose a cursor when the agent must decide whether another page is useful.

Set a maximum page count in either case. An incorrect next-page token should not create an unbounded loop.

Use the authenticated session for identity

The model may provide a resource ID. The application must get the user identity from the authenticated session.

Suppose a user asks for order A123. The dispatcher should receive two separate values:

authenticated_user = resolved from the authenticated session
arguments = { order_id: "A123" } from the model

Application code loads the order. It then checks whether the authenticated user can view that order.

Do not add customer_id to the model-visible schema. The model or user could use that field to claim another identity.

Keep user identity, roles, delegated access, and service credentials outside model-generated arguments.

Use the same public response for a missing record and a forbidden record when necessary.

This response prevents an attacker from using the tool to discover which record IDs exist.

Authorization should use both the principal and the requested resource. A broad role check such as is_support_agent is rarely enough.

The policy may need tenant, region, record owner, purpose, and requested fields. Perform that check before the source API returns private data.

Return less data

An API response often contains far more information than the current step needs.

If the model needs only delivery status, return that field. Do not return billing addresses, internal notes, cost details, or the complete API response.

Small results:

  • reduce context use.
  • lower accidental disclosure.
  • make grounded answers easier.
  • produce clearer traces.
  • reduce the number of fields an attacker can manipulate.

Keep the complete record in the database or source service. Return only the fields that the model needs for the current step.

Errors should guide safe recovery

Return a specific error category.

Use error categories with known behavior:

Error Typical response
invalid_input Correct the arguments or ask for missing input
not_found Stop or ask the user to verify the identifier
denied Stop and do not retry with variations
timeout Retry once if the operation is safe
rate_limited Back off within the run budget
upstream Retry only known transient failures
ambiguous_write Look for a receipt before doing anything else

Do not expose raw stack traces, SQL errors, access-policy details, or service credentials to the model.

Give each error a known category. Application code can then apply a fixed response.

For example, the code can stop after denied or retry once after timeout.

Application code must define the retry policy. Do not ask the model to infer retry behavior from an error message.

Keep internal diagnostic details outside the model result. Operators may need the upstream status code and request ID, but the model usually needs only the safe category.

model result:   { status: "error", code: "upstream", retryable: true }
operator trace: upstream_status=503, request_id=req_789, attempt=1

This split gives the control loop useful recovery information without placing infrastructure details in model context.

Separate reading from writing

Read and write tools have different risk.

A read can still leak data, but a write changes the world. Give them different credentials, budgets, availability rules, and approval paths.

A safe write flow often has three operations:

  1. Read the current state.
  2. Preview or propose the exact change.
  3. Commit only after policy and approval checks.

The proposal must contain every field that can change the action or its effect:

{
  "proposal_id": "proposal_123",
  "action": "refund",
  "resource_id": "invoice_456",
  "amount": 12900,
  "currency": "USD",
  "reason": "duplicate_charge",
  "policy_version": "2026-08",
  "expires_at": "2026-08-24T16:30:00Z"
}

The model may explain why the proposal seems appropriate. Application code calculates the amount and checks whether the action needs approval.

After approval, application code sends the final write request.

Bind an approval to the exact proposal. If the amount, resource, reason, policy, or expiry changes, request a new approval.

Follow a read and write end to end

The duplicate-charge request now reaches the tool layer. The model first asks for a read:

{
  "name": "invoice_get",
  "arguments": { "invoice_id": "456" }
}

The dispatcher validates invoice_id. It gets the caller from the session and checks access to invoice 456.

The adapter calls the billing API with a service credential. It returns only the invoice amount and payment summaries.

{
  "status": "ok",
  "invoice_id": "456",
  "amount_minor": 12900,
  "currency": "USD",
  "payments": [
    { "payment_id": "pay_A", "status": "settled", "amount_minor": 12900 },
    { "payment_id": "pay_B", "status": "settled", "amount_minor": 12900 }
  ]
}

The model sees evidence of two payments and requests refund_preview. The application calculates the proposal from verified payment records.

The preview tool does not move money. It returns an immutable proposal ID, exact amount, expiry, and approval requirement.

After approval, the harness calls refund_commit with the stored proposal. This commit tool is not available to the model before approval.

model -> invoice_get request
code  -> validate + authorize + read billing API
model -> refund_preview request
code  -> calculate + apply policy + store proposal
human -> approve exact proposal
code  -> refund_commit with idempotency key
API   -> durable refund receipt

The model never receives a payment credential. It cannot change the amount after approval or call an arbitrary billing endpoint.

The receipt, rather than model text, proves that the refund completed.

Make writes idempotent

Networks fail at awkward moments.

A service can complete a write and lose the response. If the caller retries blindly, the action may happen twice.

Give each intended write a unique idempotency key. Store the result with that key in the source service.

If the same request arrives again, return the stored result. Do not repeat the write.

This matters even when the tool normally runs once. Agent loops, job retries, browser retries, and process recovery can all repeat a call.

Use the stored service receipt as proof of completion.

An idempotency key needs a clear scope. The key should identify one intended business action, not one network attempt.

Store the request fingerprint with the key. Reject reuse when the amount, currency, destination, or resource differs.

If the upstream API does not support idempotency, add it in your service before exposing the write as an agent tool.

Keep the tool set small

Every tool definition consumes context and creates another choice.

Start with the fewest tools that can complete the task. Prefer names that distinguish intent clearly. Avoid several tools that perform nearly the same operation with slightly different descriptions.

As you add tools, show the model only the tools that it can use for the current step.

Filter the list by the task and the authenticated user's permissions. A documentation search does not need finance or database administration tools.

Test the tool list with realistic tasks. Record whether the model selects the correct tool and supplies valid arguments.

Programmatic Tool Calling

Some tasks require many mechanical operations before the model needs to make another judgment: fetch several pages, filter records, group results, or calculate totals.

Sending every intermediate value to the model increases cost and input size.

With Programmatic Tool Calling, the model writes a short program that calls approved tools.

A provider-hosted or application-hosted runtime runs that program in an isolated environment with strict limits. The program returns only the final result that the model needs.

Use it when:

  • the intermediate work is mechanical.
  • the allowed tools are safe to compose.
  • the sandbox has strict time, memory, network, and output limits.
  • generated code cannot access raw credentials.
  • the final result remains auditable.

Do not use generated code to bypass policy or create a general production shell.

Apply the same permission checks, resource limits, and audit logs to generated code that you apply to other executable tools.

Generated code creates additional failure modes. It can loop, allocate excessive memory, print sensitive data, or combine safe reads into an unsafe disclosure.

Run it without ambient credentials. Give the sandbox explicit tool handles and deny direct network and filesystem access unless the task requires them.

Capture its final structured output and execution metadata. Do not place a large execution log into the model context by default.

Tool failure modes

Failure Likely cause Control
Wrong tool selected Names overlap or descriptions are vague Use distinct task-level names and selection evals
Valid schema, wrong resource Arguments pass type checks but violate policy Authorize the principal against the resource
Sensitive fields reach model Adapter returns raw upstream object Define and validate a minimal output schema
Tool loops through pages Pagination has no bound Set page, item, time, and output limits
Write happens twice Caller retries after an uncertain response Use idempotency and query by key
Approved action changes Commit accepts new mutable arguments Commit an immutable stored proposal
Model retries denial Error text invites another attempt Return denied and stop in code
Generated code escapes scope Sandbox has ambient access Deny access by default and expose explicit handles

Tool tests should include these conditions as normal cases, not rare edge cases.

Test the tool and the agent

First, test the application code behind the tool without using a model:

  • valid and invalid arguments.
  • authorization.
  • pagination.
  • timeouts.
  • error mapping.
  • response validation.
  • data minimization.
  • idempotency.
  • approval mismatch.
  • duplicate write prevention.

Next, test whether the agent uses the tool correctly:

  • calls it when needed.
  • avoids it when unnecessary.
  • chooses the right tool.
  • responds safely to errors.
  • uses the returned facts.
  • stops after the task is complete.

Both test groups matter. The model can select a correct tool at the wrong time.

Application code can also expose an unsafe tool to a capable model.

Tool design checklist

  • Does the tool represent one user task rather than a transport method?
  • Are its name and description distinct from every other visible tool?
  • Are input and output schemas both validated?
  • Does identity come from the authenticated session?
  • Does authorization check the requested resource and operation?
  • Does the adapter return only fields needed for the next decision?
  • Are time, item, page, and output limits explicit?
  • Are errors mapped to stable categories with fixed recovery rules?
  • Is a write separated into preview, approval, and commit?
  • Is approval bound to an immutable proposal?
  • Can a repeated write return the original receipt?
  • Can the tool implementation be tested without a model?
  • Can an eval verify when the model should and should not call it?

Add a tool only when its contract, authority, failure behavior, and evidence are clear.

Previous: AI Agents Part 2: Harness and CLI

Next: AI Agents Part 4: MCP explains how tools and context can cross a standard protocol boundary.