The tool the model wanted to call didn’t run on my servers. It ran on a device sitting on a counter in someone else’s building, over a voice connection that could drop mid-sentence, and it had a handful of seconds to answer.
That was the good case. The bad case was the tool that existed nowhere at all: one the model invented, called confidently, and expected me to run.
This piece is about what changes when the tools stop being yours — what the arrangement actually is, how it differs from the one you’ve probably already built, and why a difference that sounds like a deployment detail turns out to reshape the architecture. The second piece is about what it costs.
How this normally works
Start with the part that trips people up, because everything rests on it.
The model cannot run code. It never touches anything. All it can do is emit a message that amounts to call refresh_display with these arguments — and something else has to actually run that, hand the result back, and ask the model again, sometimes several times over before there’s an answer worth giving. That cycle — ask, execute, ask again — is the agent loop, and one full pass through it, from what the person asked to what they finally hear, is one turn.
The menu of functions on offer is a list you hand the model at the start of that turn. In a tutorial it’s a constant in your source.
If you’ve wired up MCP, you have a slightly richer version of the same picture. You pick a tool server, connect to it, and it advertises what it can do. The list is dynamic, in the sense that the server decides it rather than you — but you chose that server. You can reach it. If it misbehaves you can redeploy it. It runs on infrastructure somebody accountable operates, and it will still be the same server in an hour.
Either way, one thing quietly stays true: the tools run somewhere you control, and the list of them is something you decided.
Now move the tool server to the other end of the conversation
Someone talks out loud to a device in a shop I don’t own. That device holds an open, two-way voice connection to a worker process of mine, which keeps the session alive and relays to my backend. The backend sends what they said to a language model.
And when the device connects, it tells the backend what it can do. One entry in that list — the manifest — is about as boring as you’d expect:
{ "name": "refresh_display",
"description": "Redraw the screen the user is looking at.",
"parameters": { "screen_id": { "type": "string" } } }
Hold on to how ordinary that looks.

The dashed line is the only part that matters.
There is no separate tool server I selected. The thing declaring the tools is the same thing the user is talking to. The declaration arrives on the same connection as the audio. I can’t redeploy it — it’s firmware in a building I don’t control, quite possibly a build I shipped eight months ago. And it can change between one sentence and the next, because the person tapped through to a different screen.
Why let the client declare anything?
The obvious objection, and the one I’d raise first: it’s your own device. Your company made it. Why not just keep the tool list on the server, where it belongs, and skip all of this?
Two reasons, and the second is the one that actually forces your hand.
The first is that the device knows things the backend cannot. Which screen is open. Whether the thing in front of the user is in a state where the action even makes sense. A server-side list can describe what the device is capable of; only the device can say what it can do right now. Offer the model a menu that ignores the difference and it will confidently pick something that can’t happen.
The second is version drift. The moment you ship hardware, the fleet stops being one thing. Some units updated last week, some haven’t been touched in a year, and you don’t get to choose. A server-side list is a claim about what’s out there; a client-declared list is a report from what’s actually out there. When the two disagree, the server is the one that’s wrong — and it will be wrong silently, which is the worst way to be wrong.
So the client declares. That decision is defensible, and I’d make it again. It also relocates your tool list from configuration you typed to input arriving over a network from a machine you don’t control, which is a different kind of object entirely.
None of which means everything moves to the client. Most tools stay exactly where they were — on the server, where you can still change them. A single turn usually mixes both, and the more interesting flows run through both in sequence.
Say someone asks a question about what they’re looking at. The client is the only thing that knows which screen is open, so that part has to come from the client. But the detail behind what’s on that screen — the full record, the history, anything the device never held in the first place — lives on the server. So the model reads the client’s state, picks an identifier out of it, and calls a server-side tool with that identifier to get the rest.
Worth noticing early, because of what it implies: client-supplied values end up as arguments to your own server-side tools. The client isn’t only declaring what it can do. It’s shaping what your backend gets asked to do. That’s the right design and I’d keep it — but it only stays safe if nothing on the server treats those values as authority, which is a thread the second piece picks up.
The tool list is a hint, not a gate
So the list is now input arriving from somewhere else. Before getting to what that costs, there’s a prior assumption worth clearing out of the way, because if you’re carrying it then everything downstream gets built wrong. It’s the mistake that took me longest to internalise, and it’s the one the title is about.
It is very tempting to believe that not showing the model a tool prevents the model from calling it. It does not. Models emit calls for tools that were never in context — because a name appeared earlier in the conversation, because something similar existed in training data, because the prompt implied a capability, or because they simply produce a plausible-looking function name. The first time I watched a stream abort on a call to a tool that did not exist, I stopped thinking of the manifest as a boundary.
The tool list is a hint to the model. The dispatch path is the gate.
Filtering is a usability mechanism. It improves selection and shrinks prompts. It is not a security mechanism, and every scoping decision you express by leaving something out of the list has to be expressed again, independently, at the moment of execution.
Which raises a fair question when the tool doesn’t run on your machine at all: what is left to gate? The answer is the forward. The device only ever executes what the backend hands it. So when the model emits a call, the backend looks it up against the manifest this client actually declared for this turn, checks that this caller is allowed it, and only then puts it on the wire. A name the model invented matches nothing, so nothing is forwarded, and the model gets told so. The list shapes what the model is likely to ask for. The forward decides whether anything happens.
That’s manageable while the list is yours. It gets considerably harder when the list arrives over a network from a machine you can’t update.
Where does the turn’s state live while you wait?
Which brings us to the decision that shapes most of what you build next — and it isn’t a security decision at all.
The agent loop is mid-turn. It needs a result from a machine you don’t control, over a network you don’t trust, on a timeline you can’t guarantee. Something has to hold the turn’s state while you wait, and there are exactly three places to put it.
In the client’s hands, as a token. The turn runs until it hits a client tool, then ends, returning “I need this, with these arguments, and here’s a token for where I was.” The client executes locally and posts the result back. You restore the saved state and continue as a fresh response. Robust — there’s no in-flight state to lose, so a resume can land on a different machine, after a deploy, minutes later. You pay with a visible seam in the conversation, and with a resume endpoint that is new attack surface.
In a durable store. Keep the stream to the user open, park the in-flight state somewhere shared while you wait, then pick it back up and carry on. Seamless for the person listening, and by far the most infrastructure: somewhere to park it, an expiry, a way to clean up turns nobody ever resumes, and a way to rejoin a stream you stepped away from. That last one is usually the real blocker — most streaming libraries will happily hand you one response, start to finish, and have no notion of pausing halfway and picking the same stream back up. It isn’t a feature you add. It’s a rewrite of your streaming layer.
In the process, because you never let go. If the client is already connected over a two-way channel, nothing needs to suspend. You call the client the way you’d call any other remote service, and the turn blocks exactly as it blocks on a database query. No checkpoint, no resume endpoint, no TTL, no reattach.

Three places to put the same thing. Only one of them needs no new infrastructure.
I wrote up the first two in full before noticing that the third wasn’t a design problem at all. The connection was already there — a realtime voice session, open for the length of the conversation, carrying audio in both directions. Once you have a duplex channel, most of the reasons to reach for the other two evaporate.
What you accept in exchange is that the turn is now sticky to one process, so losing that process kills it outright, and that blocking a server turn on someone else’s execution time makes your timeout design load-bearing rather than incidental.
So the rule is short. Already have a duplex connection? Use it. If not, and a seamless single stream isn’t a hard product requirement, use the token, accept the seam, and enjoy being able to deploy mid-conversation. If it is a hard requirement, you need the durable store — and you should scope the checkpointer, the TTL and the reattach as real projects rather than as details of the tool feature.
Where this leaves you
Set against the ordinary setup we started from, three properties changed, and none of them look like much on their own.
The tool list became a runtime value instead of configuration. It differs per caller — several products share this backend, and the same device can serve more than one of them. And it can change while a turn is still in flight.
Individually, each is an inconvenience. Together they mean the list is untrusted input on a hot path, and at that point you have stopped doing prompt engineering. You are doing trust boundaries, failure isolation and timeout budgeting, in a setting where the calling convention happens to be generated by a language model.
That’s the subject of the second piece: what it costs to take that seriously — where the names get rewritten, why every field in that innocuous JSON is an injection surface, and how a chain of individually correct timeouts adds up to telling someone an action failed after it already happened.