Write a tool

Describe tool functions

Control what the model sees and document function behaviour.

For: plugin authors.

Control what the model sees and document function behaviour.

What the model sees

Providers do not allow dots in tool names, so the model sees <tool>__<function>, for example acme_github__get_pr, and gets the function's text content as the result. Servers that return structured content also send it as text.

A tool call runs once. If it fails, the error goes back to the model as feedback, so it can try something else; it is not retried automatically, because tools can have side effects.

A function whose server marks it read-only, with MCP's readOnlyHint, is the exception: a call that fails, because its server crashed or timed out, runs again, up to three times. So is an idempotent one (idempotentHint) whose server says it honours the call's idempotency key (below); since v1.0.1 the hint alone is not enough, because an attempt that timed out may already have acted. An error the tool answers with still goes to the model. In Python's MCP SDK:

src/acme_github/server.py
from mcp.types import ToolAnnotations


@server.tool(annotations=ToolAnnotations(read_only_hint=True))
def get_pr(repo: str, number: int) -> dict: ...

Retry-safe functions

A function is retry-safe when a second call cannot do more than the first: it only reads (readOnlyHint), or it is idempotent (idempotentHint) and its server says it honours the call's idempotency key (below). A remote server's annotations are hints, so its functions are never retry-safe on their word. Only a retry-safe function's failed call runs again by itself.

Any other function's call gets one attempt. When that attempt ends without an answer the platform can read, nobody knows whether it wrote: it ran past the tool's timeout and was stopped at the tool, its node was lost while it ran, or its connection broke after the request went out.

Such a write's outcome is unknown, and the job does what its agent's on_unknown says: by default, a job a person started waits for a person, who checks the system the tool writes to and answers it happened, run it again, or stop; a routine's, an event's or an API key's job fails with the failure kind unknown_outcome, as such a call failed before. A call the node refused before making it (its server was not up), an error the tool answered with, and a kill switch's stop are known outcomes and end as before.

Honouring the key is what makes "run it again" safe: the call made again carries the first call's key, so a server that keeps its keys answers it from its record instead of writing twice.

What a definition costs

Every model request of an agent carries the definition of every tool function it may use: the name above, the description and the input schema. Long descriptions and big schemas cost tokens in every request, whether the model calls the function or not.

plugin inspect, plugin lint, the studio and the catalog show an estimate per function and per agent, and plugin lint warns when one function's definition passes 1,500 tokens (lint code tools.tokens).

Give an agent only the functions it needs, and keep descriptions to what the model must know to choose and call the function. See the catalog for how the estimate is made.

The size budget

Two budgets fail plugin lint, plugin test, plugin pack and the studio's lint when they are passed (lint code tools.budget), so they fail your plugin's CI too:

  • Per agent, limits.tool_tokens in the agent's definition: what its tool definitions come to in every model request. Unset, it is 8,000. An agent that loads its tools lazily carries only the two discovery tools and its built-ins; an agent with skills carries oz.skill too.
  • Per plugin, tool_tokens in oz0-plugin.yaml: what all the plugin's own functions come to together, whichever agent lists them. Unset, it is 16,000.

The defaults come from our own plugins: the biggest, github with 25 functions, comes to about 3,850. Both figures are estimates from the measured listing; without one, lint says so and checks nothing. A finding names the figure, the budget and the three biggest functions:

error: agents/clerk.yaml: limits.tool_tokens: agent clerk: its tool definitions come to about 2,386 tokens in every model request (an estimate), more than its budget of 2,000; the biggest: office.send_invoice (62), ...
  fix: load its tools lazily (tool_loading: lazy, which sends two discovery tools in place of every function's definition), list fewer functions, shorten their descriptions and schemas, or raise limits.tool_tokens

Raise a budget in the definition when the size is what you mean: the raised figure is then in the plugin's files for every reviewer to see. The plugin index never refuses a listing over its budget; its listing shows a warning (listing a plugin).

Say what a tool does

Annotate every function with what it does to the world. The tenant's tool policy decides from the annotations whether a call runs, waits for a person, or is refused, and shadow runs during a rollout call only read-only functions (see New versions roll out to canaries first):

src/acme_github/server.py
from mcp.types import ToolAnnotations


@server.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def get_pr(repo: str, number: int) -> dict: ...


@server.tool(annotations=ToolAnnotations(destructive_hint=True, open_world_hint=False))
def merge(repo: str, number: int) -> str: ...
  • read_only_hint=True: it changes nothing. It may run again when a call fails, and shadow runs call it.
  • destructive_hint=True: it can delete or overwrite. Say it: a function that is not read-only and leaves it out counts as undeclared, which waits for a person since v1.0 (MCP reads it as destructive). destructive_hint=False: it writes, but only adds.
  • idempotent_hint=True: calling it twice does what calling it once does.
  • open_world_hint=False: it stays inside your systems; leave it out (MCP's default is True) when it reaches the internet.

A function without annotations counts as destructive and open-world under MCP, and so does one that is not read-only and leaves out destructive_hint; both have a kind of their own, undeclared, which since v1.0 waits for a person's approval at each call unless the tenant allows it, and the catalog marks them. plugin new writes annotated tools, and oz0 dev lists the functions the tool policy does not let run freely, with the fix.

Its manual

A tool's descriptions say what each function does; a skill can say how to use them well together: which to call first, what to check, what never to do. Name such skills of your plugin as the tool's manual:

oz0-plugin.yaml
tools:
  - id: acme_github
    mcp: ["python", "-m", "acme_github.server"]
    manual: [acme-github]
skills: [skills/]

manual lists at most 8 skills of the same plugin, by name or as <plugin>/<skill> with your plugin's own name; another plugin's skill is refused. Every agent granted any of the tool's functions then gets them in its skills index, another plugin's agents too under their grant of the tool's functions, without a skills grant. See A tool's manual for who gets it, the budget, and when reading it taints a job.

Copyright © 2026