We wired an in-app AI agent into a product that already had a full write/query surface. Auth was fine. The agent had permission to call writes. Half the handlers it needed simply never showed up in its tool list.
The agent said it couldn't find them. The handlers existed. What was missing was a description string.
Our agent doesn't get raw TypeScript. It gets a manifest built from every feature the app mounts: handlers, screens, entities. Each entry needs a short description, written at the place the handler is defined. Without that string, the entry is dropped. The agent never learns the name exists.
That surprised a few people, including me the first time. We treated descriptions as documentation polish. For the agent they are the gate. An endpoint without an OpenAPI description might still be callable if you know the path. An agent-facing handler without a description isn't callable at all, because the model never sees a tool to choose.
Twenty-one handlers across our AI pipeline and prompt store features were in that state. The code ran for humans in the UI. The agent walked past them.
You also can't patch this later at the composition site. The description has to live next to the handler definition, so every app that mounts the feature inherits it. We learned that the hard way: people tried to "add docs" in the app and the gap tests kept failing, because the packaged feature still shipped blank.
Visibility is half of it. The other half is what happens after the agent picks a tool.
Writes default to risk mid. Queries default to low. The UI can offer "always allow" for those. Two of our writes are different:
{
name: "delete-golden",
description:
"Permanently deletes a golden fixture, so it can no longer be used for dry-runs. This is a hard delete, not an appended revision, and cannot be undone.",
agent: { risk: "high" },
// ...
}
{
name: "edit",
description:
"Saves new content for a prompt template by appending a revision that becomes the active prompt immediately… The owning AI feature uses this content verbatim as its system prompt…",
agent: { risk: "high" },
// ...
}
delete-golden is a hard delete. Prompt edit is free text that becomes another feature's system prompt on the next run. Both are marked high. The permission loop refuses always for those handlers outright. You can still approve a single call. You cannot teach the agent that this shape of write is permanently fine.
Most of the neighbouring writes append a restorable revision (set-policy, rollback, prompt revert). Those stay at the default. Restorable side effects and irreversible ones share a form in the code; they don't share a permission ceiling.
Phone apps already do this split. Camera access can be "always". Wipe storage can't. We needed the same ceiling for an agent that can press buttons faster than a human reviews them.
Once you decide "no description means invisible", silence becomes the failure mode. The agent looks underpowered. Nobody opens a ticket that says "missing string on handler 14".
So each feature that should be agent-visible gets a gap test:
test("createAiPipelineFeature([]) exposes 0 doc gaps", () => {
const gaps = findAgentDocGaps([createAiPipelineFeature([])]);
expect(gaps).toEqual([]);
});
test("delete-golden is high risk, set-policy is mid risk", () => {
const feature = createAiPipelineFeature([]);
expect(resolveAgentExposure(feature.writeHandlers["delete-golden"], "write").risk).toBe("high");
expect(resolveAgentExposure(feature.writeHandlers["set-policy"], "write").risk).toBe("mid");
});
The first test is the smoke alarm for missing descriptions. The second pins the two risk decisions we actually care about, so a refactor can't quietly demote a hard delete back to "always allow"-eligible.
We also run the same gap lint from a CLI against a whole app config. Shipping a new consumer feature without descriptions turns red before anyone asks the agent to do a demo.
If a human can click it and an agent should be able to call it, write the description where the handler is defined. One sentence that says what changes and what stays reversible is enough; our pipeline descriptions are longer because the agent needs the follow-up query names (revisions before activate).
If the write can't be undone, or it rewrites another feature's brain, mark it high. Don't rely on the operator to remember which "always allow" clicks were a bad idea.
If you only remember one check: ask the agent to list what it can do, then compare that list to your write handlers. The missing names are usually missing strings, not missing models.
I wrote earlier about schemas beating prose for tool outputs. This is the other side of the same habit. There the description was advice and the schema was the contract. Here the description is the contract for whether the tool exists at all, and risk is the contract for whether "always" is even legal.