Absent, not refused
A tool the caller can't use isn't built at all. One function builds the list for the model's turn, the public manifest and the debug door, so none of them can offer what another would refuse.
The easy way to gate an assistant’s tools is inside each tool: check the permission in run, return “you can’t do that”. The model still sees the tool. It tries it, burns a round, maybe tries again with different arguments, and the assistant becomes a way to poke at a gate the UI already hides. Meanwhile the manifest the client shows and the debug list are maintained separately, and they drift from what a turn can actually do.
So the tool list is the permission. A tool whose preconditions don’t hold is never constructed.
export function buildToolList(view: View | null, { catalog, permissions, services }: ToolOptions): Tool[] {
const isAdmin = permissions?.includes("orders:admin");
return [
askDataTool, findRecordTool, searchDocsTool,
...(isAdmin ? [getAuditReportTool] : []), // the caller's permissions
...(catalog.screen?.filter ? [filterTool(catalog.screen.filter)] : []), // what the page declares
reviseProposalTool(services.reviser, view), // a backend + state on screen, or null
...pageActionTools(view, { taken: SERVER_TOOL_NAMES }), // page actions never shadow a server tool
].filter((tool) => tool !== null);
}
function reviseProposalTool(reviser: Reviser | undefined, view: View | null) {
if (!reviser || proposalOnScreen(view) === undefined) return null;
return new Tool({ name: "revise-proposal", /* … */ });
}
┌─▶ the model's tools for this turn
buildToolList(request) ─┼─▶ GET /assistant/manifest (built with describe-only workers)
└─▶ /tool <name> (404 if it isn't in the list)
Rules
- One builder, three readers. The turn, the manifest and the debug
/tooldoor all call the same function, so they can’t disagree. The manifest passes stand-in workers that throwdescribe-onlyif run, so a tool backed by a service is still listed without being callable. - The prompt follows the list. Prompt sections are derived from the tools that were built. The “act on the page first” section appears only when an action tool exists, and the kinds the page can make are trimmed to what
generatewas given. - Commands use the same gate. The slash-command registry filters by the same permission list, and that list matches the gate the UI and the API use.
- Still check inside. A tool that exists because a proposal was on screen re-checks in
run, because the screen can change between the request and the call.
What it gives up
The model can’t say “you’d need admin for that”, because it doesn’t know the tool exists. A per-request list also means the tool spec changes from page to page, which costs some prompt-cache hits on the tools block.
Why it works
Nothing is offered that will be refused, so the model never wastes rounds on a wall, and the assistant can’t do more than the person can. Adding a gate is one conditional in one function, and every surface that lists tools picks it up.
How this fits the larger system: The UI is the engine.