Designing the tool surface
A widget nobody triggers is worth nothing. Discovery is a metadata problem, and metadata is where most agent UIs quietly fail — the UI is beautiful and the model never calls it.
Naming
Action-oriented verb_noun, lowercase, underscore-separated.
✅ search_listings render_listings_widget complete_checkout
❌ listings handler doStuff get_data
The name is the model's strongest signal. render_listings_widget announces
what it does and that it produces UI; listings announces nothing.
Consistent prefixes across a server help the model reason about a family:
search_*, get_*, render_*, create_*, update_*.
Descriptions
The description is a prompt. Write it for the model, not for a docs page.
Cover four things:
- What it does, concretely, including the return shape.
- When to call it — the user situations that should trigger it.
- What it depends on — required prior calls, spelled out.
- When not to call it — the near-miss cases it should not absorb.
description:
"Render the listings widget from prepared listing data. " +
"Call search_listings first and pass the filtered listing IDs here. " +
"Use when the user should visually compare properties. " +
"Do not use for a single property — use render_listing_detail instead.",
Dependency statements are load-bearing in the decoupled pattern. Without
"call roll_dice first", the model will invent arguments for the render tool.
Schemas
Input
- Every parameter needs a description; the model reads them.
- Constrain: enums over free strings,
min/maxon numbers,maxLengthon strings,maxItemson arrays. - Required means required. Optional parameters with sensible defaults reduce malformed calls.
- Keep the surface small. A 15-parameter tool gets called wrong.
inputSchema: {
city: z.string().min(1).max(120).describe("City name, e.g. 'Portland, OR'"),
days: z.number().int().min(1).max(14).default(7).describe("Forecast length in days"),
units: z.enum(["metric", "imperial"]).default("metric"),
}
Output
Declare outputSchema whenever you return structuredContent. It documents the
contract for the model, for the View, and for your own tests.
The three result channels
return {
structuredContent: { city, days }, // View renders this; model can inspect it
content: [{ type: "text", text: `7-day forecast for ${city}.` }], // model reads; text-only hosts read
_meta: { /* client-specific, hidden from the model */ },
};
| Channel | Who sees it | Put here |
|---|---|---|
structuredContent |
View + model | The data. Concise, typed, matching outputSchema. |
content |
Model + text-only hosts | A short natural-language summary. |
_meta |
Client only | Rendering hints, session tokens, anything the model must not reason over. |
content is not optional. It is what makes the tool work in a host that
does not render UI, and what lets the model answer a follow-up without
re-calling. A tool whose only output is pixels is broken.
Keep structuredContent lean. It enters the model's context on every call —
returning 500 rows to render 8 wastes budget and degrades reasoning.
Visibility
| Setting | Effect | Use for |
|---|---|---|
["model", "app"] (default) |
Model and View may call | Normal tools |
["app"] |
View only; hidden from the model | Pagination, polling, cart mutations, chunked transfer |
["model"] |
Model only | Sensitive operations the UI must not initiate |
App-only tools are the main lever for keeping conversation context clean. A View that calls a model-visible tool on every interaction floods the transcript.
visibility is context hygiene, not access control. Authorize server-side
regardless.
ChatGPT's _meta["openai/widgetAccessible"] is the older equivalent; M365
Copilot honors openai/visibility but not widgetAccessible. Prefer
_meta.ui.visibility.
Safety annotations
annotations: {
readOnlyHint: true, // widely honored
destructiveHint: false, // ignored by M365 Copilot
idempotentHint: true, // ignored by M365 Copilot
openWorldHint: false, // ignored by M365 Copilot
}
Set them all — they are cheap and some hosts use them. But never rely on
destructiveHint to produce a confirmation prompt. Build confirmation into
your own UI for anything irreversible.
Invocation status text
_meta: {
"openai/toolInvocation/invoking": "Searching listings…",
"openai/toolInvocation/invoked": "Found 12 listings.",
}
ChatGPT-only; unsupported in M365 Copilot. Put a real status in the widget itself so the experience holds up everywhere.
Discovery
Whether the model reaches for your tool at all comes down to:
- Name — the primary signal.
- Description — trigger situations in the user's vocabulary, not yours.
- Parameter descriptions — they disambiguate between similar tools.
- Server/app description — the umbrella context.
Test discovery explicitly: write the five phrasings a real user would use and verify each selects the right tool. Wrong selection is a metadata bug, not a model failure — fix the description.
Tool count
Under ~10 well-named tools, models select reliably. Past ~20, selection degrades noticeably.
- Prefer one tool with an enum parameter over five near-identical tools.
- Hide mechanical helpers with
visibility: ["app"]— they cost nothing in the model's list. - Split a server that has genuinely outgrown one domain.
Anti-patterns
| Anti-pattern | Why it fails |
|---|---|
resourceUri on every tool |
Widget remounts on every call; state lost |
No content, only structuredContent |
Breaks text-only hosts and follow-ups |
| Description written for humans | Model cannot tell when to use it |
| Unconstrained string parameters | Malformed calls, injection surface |
| Returning the whole dataset | Burns context, degrades reasoning |
| Business logic in the render handler | Untestable; re-renders do real work |
Relying on destructiveHint for confirmation |
Ignored by major hosts |
Related
openai-apps-sdk— the decoupled data/render pattern.host-capability-matrix— which_metafields each host honors.ui-security-sandbox— why server-side authorization is non-negotiable.