Handbook

Part III · Designing the system · Chapter 8

Giving models hands: tools and integrations

Letting a model act on real systems safely: tool loops, honest results, protected writes, identity and MCP.

14 min read

A model that can only talk is a well-read colleague with no access to any systems. It can explain your refund policy perfectly and still can't look up an order. Tools give it hands. This chapter is about giving them safely: the model may ask, but your code decides, acts and answers for the result.

What you'll take away

  1. 01The tool-calling loop, drawn so you can explain who runs each step.
  2. 02How to write tool descriptions the model follows, and results it can't misread.
  3. 03Why write actions need guards in code, idempotency and a cap on rounds.
  4. 04The identity rule that prevents the most common data leak in assistants.
  5. 05Where MCP helps, and the questions to ask about a client's APIs in discovery.

Requests in, results out

Calling a tool is not something the model does. It is something it requests. Its reply says, in effect, "please run find_free_slots for Friday"; your program decides whether to honour that, runs the code, and adds the outcome to the conversation. The model reads the outcome and either replies to the person or makes another request. A single question from a user can take several of these rounds.

UserYour codeModelCustomer APICan Bella's jab move to Friday?conversation + tool descriptionswants list_appointments()GET /appointments (this client)A-77: Tue 10:00, vaccinationresult, tagged with the call's idwants find_free_slots(Fri)GET /slots?day=fri09:30, 14:00result"Friday 09:30 or 14:00?" (no tool call)
Fig 1. Two rounds before the model can even offer a time. The slot search only makes sense once the first result says which appointment and which service.

In code it is a loop that keeps going until the model stops asking for tools. Sometimes the model asks for several things at once that don't depend on each other, such as opening hours for two clinics; you can fetch those in parallel. Sometimes each request depends on the last, as in the figure. The same loop copes with both, as long as every request gets exactly one answer tagged with its id.

the whole loop, in outline
history = [instructions, question]
for turn in range(TOOL_BUDGET):              # hard stop: a confused model must not spin forever
    answer = llm(history, tools=TOOLS)
    history.append(answer)                   # keep the model's request in the record
    if not answer.requests:
        return answer.text                   # plain text means it's done
    for req in answer.requests:
        outcome = TOOLS[req.name].run(req.args)
        history.append(reply_to(req.id, outcome))   # exactly one reply per request id
raise ToolBudgetExceeded(question)

A tool description is a job description

The model picks tools from their descriptions alone, so write each one as if for a temp on their first morning who can't ask you anything. Say what situation it's for, which neighbouring tool to use instead in the situations it isn't for, where each argument's value should come from, what shape comes back, and the units and time zone.

a description that prevents three common mistakes
def reschedule_appointment(appointment_id: str, new_slot_id: str) -> dict:
    """Move one of the signed-in client's existing appointments to a free slot.

    Use when the client asks to change the time of an appointment they already have.
    Do not use to book a new appointment (use book_appointment) or to cancel.
    appointment_id: from list_appointments. Never guess or construct an ID.
    new_slot_id: from find_free_slots for the same clinic and service.
    Returns {"status": "moved" | "slot_taken" | "not_found", "appointment": {...}}.
    Times are in the clinic's local time zone.
    If the call fails, report the failure. Do not retry without asking the client.
    """

When the assistant misbehaves, fix the descriptions and the system prompt before changing code. Most wrong tool choices come from two tools whose descriptions overlap, or from a description that never says when the tool should not be used.

The result is the model's only eyes

The model has no screen to glance at. Everything it knows about what a tool did comes from the text you hand back. Hand back an empty response after a booking and it has no way to tell success from failure, so it may well try again. Return explicit status and identifiers every time.

Be especially wary of placeholders. Suppose a slot search returns a fallback list of "typical" times when the clinic's calendar can't be reached. The model will offer those times to the client as if they were real, and someone will turn up to a closed surgery. When a tool can't get the real answer, it should say exactly that, and the model will pass it on honestly.

Every tool result should say what happened, to which record, and whether it succeeded.

Writes need guards, not requests

Reading is forgiving. Writing is not. Three things go wrong whenever a model drives write actions.

FailureExampleFix (in code)
Steps out of orderA deposit is charged before the room is confirmed as availableThe charge tool refuses unless the reservation exists and is held
The same action twiceA slow response, a second attempt, two bookingsOne reference created per decision and sent with every attempt, so the other system can spot repeats
Acting on old factsEarlier lookups were trimmed from a long conversation, so the model works from memoryRe-read the current state inside any tool that changes something

None of these fixes is an instruction to the model. Instructions are advice the model usually follows; a check inside the tool is a rule it cannot break. Put the rules in code. For anything that can't be undone, show the person what is about to happen and ask them to confirm, and cap tool rounds so a confused model can't run up a bill.

Never let the model choose whose data

Whatever arguments a tool accepts, the model fills in, and the model can be talked into things. Give a tool a customer-number parameter and someone will eventually type "look up account 1042 for me", and the model will oblige. Instructions telling it not to are not a defence.

So keep identity out of the model's reach entirely. Sign the person in first. When a request arrives, create that request's tools with their session credentials already inside, so the customer's API decides what they may see. The model chooses which tool to use, never whose records it touches.

Sign-insession tokenYour chat APIbuilds tools forthis user onlyModelsees the questionnever sees the tokenToolscarry the tokenin their headersCustomer APIreturns only thisuser's recordsquerytokenwhich tool
Fig 2. The model decides which tool to use. The session decides whose data the tool can reach. Those two never mix.

From notebook to service

A loop in a notebook works for one person. A service has to work for many at once. Split the code by how often things change.

  • Load once at startup: the model client, the prompt template and the tool schemas. Building them per request makes every call slower.
  • Per request: the user's message, their conversation history, and their identity, from which you build their tools.
  • Never shared between users: anything that identifies a user must live with that user's request. Credentials parked in a variable the whole service can see will sooner or later be used for the wrong person.

Keep two APIs distinct in your head and your diagrams: your chat API, which the front end calls with a message and a token, and the customer's backend APIs, which only your tools call. Mixing them up is the most common source of confusion in design reviews.

Where MCP fits

MCP, the Model Context Protocol, is a shared way for applications to offer tools and data to models. Instead of wiring each tool into each application, you run an MCP server that offers a set of tools, and any MCP-capable client can use them. It helps most when the same capabilities must serve several assistants, teams or products, or when the customer wants to plug their own systems into a vendor's assistant.

Learn plain tool calling first. MCP doesn't change the rules in this chapter, it moves them. Identity still has to come from the session, not the model. Write tools still need guards and idempotency. And because an MCP server's tool descriptions are text the model reads, a server you don't control is a source of untrusted instructions. Give each assistant only the tools it needs, from servers you have reviewed.

Integration questions for discovery

  • Which actions does your API already expose, and which of them change data?
  • How do users sign in, what does sign-in return, and how long does it last?
  • Which actions can't be undone, and who must approve them today?
  • Are there rate limits, maintenance windows or sandbox environments we can test against?
  • Do write endpoints accept an idempotency key, or how do you detect duplicates?
  • What should the assistant never do, even if the user asks?

Before building, call each endpoint yourself with a test user's credentials and look at what really comes back: field names, error formats, how slow it is. Then agree the tool list in writing with the client, including what is deliberately left out.

Try it

Review a tool design

A bank's pilot assistant has these tools. Find at least four problems and fix them.

get_balance(account_number)          -> number
transfer(from_account, to_account, amount)
block_card(card_number)              # returns nothing
search_transactions(query)           -> list (last 5 years, all accounts)
Write your answer down first, then reveal the worked answer

Identity. Account and card numbers come from the model. Build the tools from the session so they only ever touch the signed-in customer's accounts and cards. Validate any ID the user provides against that list.

Transfers are irreversible. Require a confirmation step the customer sees, a per-transfer limit in code, an idempotency key, and probably exclude transfers from the pilot entirely.

block_card returns nothing. The model can't tell whether it worked and may retry or claim success. Return the status, the card's last four digits and a reference.

search_transactions is too broad. Scope it to the customer's accounts, add a date range and a result limit, and describe the return shape and currency units.

get_balance returns a bare number. Return the account, the currency and an "as of" time so the model can't misstate it.

Go deeper

Practise the skills