Noodle Seed
Guides

Designing tools for agents

What separates a usable agentic product from an API wrapper. Task-shaped tools, host-facing titles and hints, bounded outputs, a small surface, and deliberate context.

Your MCP server can be perfectly valid and still be a bad product. The protocol says nothing about whether a model can actually accomplish what your user asked for. That is decided by tool design: how you name and scope tools, what you return, and how much the model has to guess.

This page is the doctrine. Every rule here is reported by noodle check, so you can hold yourself to it mechanically instead of by memory.

Design for jobs, not endpoints

The most common failure is a one-to-one wrapper: one tool per API endpoint, named after the endpoint. It compiles, it lists, it passes review, and it still cannot do the job, because it forces the model to orchestrate low-level calls and to know identifiers your user never says out loud.

Shape tools around what a user asks for. When an action needs an id the user does not know, pair the id-taking tool with a resolver that returns model-friendly summaries, and say so in the description so the model knows the order to call them in.

Endpoint-shaped: the model has to invent an id
tool('patch_task', {
  description: 'PATCH /tasks/{id}',
  input: z.object({ id: z.string(), status: z.string() }),
  fulfil: ({ input, connectors }) => connectors.tasks.patch(input),
});
Job-shaped: resolve by text, then act
tool('find_tasks', {
  title: 'Find tasks',
  description:
    'Find tasks whose text matches a query. Call this first to turn a task the user names in words into its id, then pass that id to complete_task.',
  input: z.object({ query: z.string() }),
  output: z.object({
    matches: z.array(z.object({ id: z.string(), title: z.string() })).max(20),
  }),
  annotations: annotations.readOnly(),
  fulfil: ({ input, connectors }) => connectors.tasks.search_tasks({ query: input.query }),
});

tool('complete_task', {
  title: 'Complete task',
  description: 'Mark a task complete by its id. Get the id from find_tasks.',
  input: z.object({ id: z.string() }),
  output: z.object({ ok: z.boolean() }),
  annotations: annotations.action(),
  fulfil: ({ input, connectors }) => connectors.tasks.close_task({ id: input.id }),
});

Combining several backing calls into one recorded flow is fine and often better. The unit of design is the user's intent, not your API's route table.

Give every tool a title and annotations

title is the human-readable action name a host shows in its UI and in confirmation prompts. The annotations helper declares behavior hints so a host can plan: which tools are safe to call speculatively, which change the world, which are destructive.

annotations.readOnly()      // safe read, closed world
annotations.action()        // affects the world
annotations.localAction()   // affects only this app's data
annotations.openAction()    // reaches the open internet

Keep reads and writes in separate tools. A tool that lists and mutates in one call cannot be annotated honestly, and no host can gate it correctly.

Both the ChatGPT Apps and Claude connector directories reject servers whose tools are missing titles or read-only and destructive hints, so this is also the cheapest submission blocker to eliminate.

Bound every output

Always declare an output schema. An untyped result forces the model to parse prose, and it gives hosts nothing to render. Then bound it: a list tool that can return ten thousand rows will either blow the context window or get truncated somewhere you do not control.

Two honest ways to bound a list, both declarative:

// Cap the shape itself.
output: z.object({ orders: z.array(orderSummary).max(50) });

// Or take a bounded page from the caller.
input: z.object({ limit: z.number().int().min(1).max(50).default(20), cursor: z.string().optional() });

Return the few labelled fields the model needs, not the raw upstream payload. Every field you pass through is context the model pays for on every turn.

Keep the tool surface small

Tool selection gets worse as the list grows. noodle check warns above 20 model-visible tools, which is a practical heuristic rather than a published host limit: no host documents a hard number, and the real threshold depends on how distinct your descriptions are.

When you cross it, the fix is usually to collapse variants rather than to delete capability. Five tools that differ only by a filter are one tool with a typed enum. Helpers that only exist for your widget can be marked visibility: ['app'], which keeps them callable from the app surface and out of the model's list.

Provision context deliberately

Most "the model guessed wrong" bugs are missing context, not a missing tool. The model does not know today's date, the user's time zone, which workspace they are in, or what your app calls things.

Declare ambient facts once on the server, and expose application context as a single zero-input tool:

server('acme', {
  title: 'Acme',
  version: '1.0.0',
  context: { defaults: { locale: 'en-US', timeZone: 'America/New_York' } },
}, [
  tool('acme_context', {
    title: 'Acme context',
    description: 'Current workspace, plan, and permissions for the signed-in user.',
    contextProvider: true,
    input: z.object({}),
    output: z.object({ workspace: z.string(), plan: z.string() }),
    annotations: annotations.readOnly(),
    fulfil: ({ connectors }) => connectors.acme.me({}),
  }),
]);

Every invocation already receives a server-authoritative timestamp, so never ask the model for "today". The embedded assistant preloads the context provider each turn; other MCP hosts call it like any other tool.

Write errors an agent can act on

An agent cannot recover from "Request failed". Say what happened and what to call next: which argument was wrong, which tool resolves it, whether retrying will help. Error text is part of your tool's interface, and it is read far more often by a model than by a human.

Check your work

noodle check                        # every rule on this page, plus MCP Apps readiness
noodle check --min-severity warn    # only what needs fixing
noodle check --json                 # machine-readable, for a coding agent

The tool-design findings are tool_design_titles, tool_design_output_shape, tool_design_output_bounds, tool_design_surface_budget, and tool_design_context. They report as warnings, never as failures, so they will not break an existing pipeline. Directory submission requirements are checked separately with noodle check --target chatgpt and noodle check --target claude.

Building with a coding agent? The same doctrine ships inside the Noodle Seed skill your agent reads, so it applies these rules while it writes your server.ts. See Build with coding agents.

On this page