Agent Skills: Designing the tool surface

This skill should be used when designing the tool surface behind an agent UI — action-oriented naming, descriptions that drive correct model selection, input and output schemas, structuredContent vs content vs _meta, safety annotations, tool visibility, and the discovery metadata that decides whether the app ever gets invoked.

UncategorizedID: lobbi-docs/claude/tool-metadata-design

Install this agent skill to your local

pnpm dlx add-skill https://github.com/TheLobbi/claude/tree/HEAD/plugins/mcp-apps-studio/skills/tool-metadata-design

Skill Files

Browse the full folder contents for tool-metadata-design.

Download Skill

Loading file tree…

plugins/mcp-apps-studio/skills/tool-metadata-design/SKILL.md

Skill Metadata

Name
tool-metadata-design
Description
This skill should be used when designing the tool surface behind an agent UI — action-oriented naming, descriptions that drive correct model selection, input and output schemas, structuredContent vs content vs _meta, safety annotations, tool visibility, and the discovery metadata that decides whether the app ever gets invoked.

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:

  1. What it does, concretely, including the return shape.
  2. When to call it — the user situations that should trigger it.
  3. What it depends on — required prior calls, spelled out.
  4. 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/max on numbers, maxLength on strings, maxItems on 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 _meta fields each host honors.
  • ui-security-sandbox — why server-side authorization is non-negotiable.