# Tool contracts

Version the tool definitions your model sees, and generate typed bindings from them.

When you give a model tools, three pieces of text decide how it behaves: the **name** it emits, the **description** that steers when it reaches for the tool, and the **JSON Schema** of the arguments it must produce. That text is as prompt-critical as the template itself — and it usually lives unversioned in application code, copy-pasted between prompts, drifting.

A **tool contract** makes it a first-class registry entity, versioned exactly like a prompt. A prompt version *pins* the contracts it depends on, `sufleur install` brings them down with the prompt, and `sufleur generate` turns them into typed bindings your implementation has to satisfy.

> [!NOTE] A contract is not an implementation
>
> A tool contract carries no code and no execution loop. It describes the interface; your own
> service still does the work, and you still own the conversation loop with the provider. Sufleur
> versions the part the model reads.

## What a contract holds

A tool is workspace-scoped and addressed as `@workspace/name`, a version as `@workspace/name@1.2.0`. Everything that steers the model lives on the **version**, so it freezes when you publish:

| Name | Type | Description |
| --- | --- | --- |
| `name` | `on the tool` | The registry name, and the default wire name the model sees. Stricter than a prompt name — see below. |
| `description` | `on the tool` | The catalog blurb, used in listings and search. Unversioned, and never sent to the model. |
| `modelDescription` | `on the version` | The model-facing text that decides whether the tool gets called. Versioned and frozen on publish. |
| `inputSchema` | `on the version` | JSON Schema for the arguments the model emits. Must be an object at the root — providers require it. |
| `outputSchema` | `on the version` | Optional. What your implementation returns; any shape. Becomes the static return type in generated code. |
| `readme` | `on the version` | Documentation for humans. Not sent to the model. |
| `metadata` | `on the version` | A free-form JSON object for your own bookkeeping. |

Tool names must match `^[a-z][a-z0-9_-]{0,63}$` — lowercase, starting with a letter, and **no dots**, unlike prompt names. The name doubles as the wire name the model emits, and providers constrain that to `^[a-zA-Z0-9_-]{1,64}$`.

> [!WARNING] Two descriptions, one letter apart
>
> The tool's `description` is a catalog blurb the model never sees. The version's
> `modelDescription` is the text that actually steers tool selection. Conflating them is the easy
> mistake here — a beautifully written catalog blurb does nothing for the model, and a
> model-steering description in the blurb field is neither versioned nor delivered. In the CLI they
> are `tool update --description` and `tool version set-description` respectively.

## Versions and visibility

Tools use the same draft-then-publish model as prompts and datasets: **one draft at a time**, mutable while you iterate; **published** versions are immutable semver snapshots. Opening a new draft carries the last published contract forward so you can edit from it.

A tool is **private** when created. Making it public is what lets other workspaces read and pin it.

## Pinning a tool to a prompt version

Pins live on a **prompt version**, not on the prompt — they freeze on publish alongside the version's files and output schema. Each pin names one concrete tool version, under the **wire name** the model will emit:

```bash
sufleur version tools add @acme/daily-brief@draft @vendor/web-search@^1.2.0 --as web_search
```

A few things follow from that shape:

- **A pin is a version, not a range.** The constraint (`^1.2.0`, `*`, `1.2.0`, or `draft`) is resolved **once, at link time**, and the concrete version is stored. A published prompt version froze the tool's description as part of the text it was tested against, so the pin has to freeze too — it never moves afterwards. The command prints which version it resolved to.
- **`--as` sets the wire name**, defaulting to the tool's own name. It exists so two tools that share a bare name across workspaces can be told apart inside one prompt. Two pins cannot share a wire name within a version.
- **A prompt version pins at most one version of any given tool**, which is why `rename` and `remove` identify a pin by the tool alone, with no version.
- **Cross-workspace pins are the point.** You can pin any tool you can read, which is what publishing a tool is for.

### Rules the registry enforces

| Name | Description |
| --- | --- |
| `Pins are draft-only` | Changing pins on a published prompt version is rejected. Pin against @draft. |
| `No draft pins on publish` | Publishing a prompt version is rejected while any pinned tool version is still a draft — a frozen version cannot point at something mutable. |
| `Public prompts need public tools` | Making a prompt public is rejected while any of its published versions pins a non-public tool, so a reader can always resolve everything the prompt depends on. |
| `Tools cannot be withdrawn` | Making a tool private is rejected while a published prompt version elsewhere — or a public one — pins it. Deleting a tool with published dependents is rejected outright. |

Taken together these mean a prompt can never reach an audience that cannot resolve everything it depends on, and a tool cannot be pulled out from under its consumers.

## Authoring in the web app

Tools live under **Tools** in the workspace nav. The list gives you search and a create dialog; a tool's page shows its model description, both schemas, its versions and how many prompt versions depend on it.

The draft editor is where the contract is written — the model description plus the input and output schemas. Pinning happens in the **prompt version editor**, where `tools` sits in the FILES list alongside `outputSchema` and `metadata`, because a pin is version content in exactly the same sense. A published version shows its pins read-only.

Publishing a tool version, changing its visibility, and deleting a tool are web-app actions only.

## Authoring from the CLI

The `sufleur tool` group covers everything up to publish, so an agent or a CI job can build and validate a contract without leaving the terminal.

```bash
sufleur tool create @acme/fetch-page --description "Fetches a URL and returns its text"
sufleur tool dump   @acme/fetch-page@draft --to ./tool
```

`dump` writes six files — `input-schema.json`, `output-schema.json` (absent if unset), `description.md`, `README.md`, `metadata.yaml`, and a read-only `tool.yaml` of catalog metadata. Edit them, then push each piece back:

```bash
sufleur tool schema set @acme/fetch-page@draft --file ./tool/input-schema.json
sufleur tool schema set @acme/fetch-page@draft --output --file ./tool/output-schema.json
sufleur tool version set-description @acme/fetch-page@draft --file ./tool/description.md
sufleur tool version set-readme      @acme/fetch-page@draft --file ./tool/README.md
sufleur tool version set-metadata    @acme/fetch-page@draft --from-file ./tool/metadata.yaml
```

The rest of the surface:

| Name | Description |
| --- | --- |
| `tool list @ws / tool get @ws/name` | Browse the workspace catalog; get shows metadata, versions and the dependent count. |
| `tool update @ws/name --description` | Set the catalog blurb. Not the model-facing text — that's tool version set-description. |
| `tool version draft @ws/name` | Open the next draft, carrying the last published contract forward. |
| `tool version list / get / delete` | List versions (--status DRAFT\|PUBLISHED), inspect one, or delete an open draft. |
| `tool schema get @ws/name@version` | Read one schema back without dumping the whole version. --output for the output schema, --file to write it. |
| `version tools list / add / rename / remove` | Manage what a prompt version pins. rename and remove take the tool without a version. |

`sufleur version dump` also writes a `tools.yaml` listing the version's pins. It is **informational** — nothing reads it back; edit pins with `version tools`.

> [!NOTE] set-metadata is a read-modify-write
>
> Unlike a prompt version's metadata, a tool version's is a plain JSON blob with no per-key mutation
> on the server. `--from-file` replaces the whole object, and the typed `--string` / `--delete` flags
> fetch it, patch it, and write it back whole — so two concurrent edits can lose one another.

### Write schemas the generators can express

`tool schema set` validates the schema **locally, before it touches your credentials**, against the subset both code generators can model. The registry itself accepts more, but anything outside that subset silently degrades to `unknown` in TypeScript and `Any` in Python — a failure you would only notice weeks later, reading the generated file.

**Supported:** `type` (`string`, `integer`, `number`, `boolean`, `null`, `object`, `array`), `properties`, `items` with a single schema, `required`, `enum` with values all of one type, `oneOf` / `anyOf`, plus `description`, `title` and `default`.

**Rejected**, with a JSON Pointer to the offending property: `$ref`, `$defs`, `allOf`, `not`, `if`/`then`/`else`, `patternProperties`, a schema-valued `additionalProperties`, tuple-typed `items`, a mixed-type `enum`, a list of types (use `anyOf`), and a `required` entry with no matching property.

## Using pinned tools in code

`sufleur install` fetches a prompt's pinned contracts along with its files, and `sufleur generate` emits, for each pinned contract: a validating argument schema and input type, a static output type, and a function type your implementation must satisfy. Each prompt that pins something also gains two methods.

| Name | Description |
| --- | --- |
| `toolDefs()` | Provider-neutral { name, description, input_schema } for every pinned tool, ready to hand to an SDK. |
| `dispatchTool(name, rawInput, tools)` | Validates the model's arguments, calls your binding, and returns { success: true, content } or { success: false, error, code } where code is unknown-tool, input-validation, or execution. |

#### TypeScript

```ts
import Anthropic from '@anthropic-ai/sdk';
import { getPrompt } from './generated/prompts';

const anthropic = new Anthropic();
const brief = getPrompt('@acme/daily-brief');

const res = await anthropic.messages.create({
  model: 'claude-sonnet-5',
  max_tokens: 1024,
  messages: [{ role: 'user', content: brief.render('main', { topic }).prompt }],
  tools: brief.toolDefs(),
});

for (const block of res.content) {
  if (block.type !== 'tool_use') continue;

  const out = await brief.dispatchTool(block.name, block.input, {
    'fetch-page': async ({ url }) => fetchPage(url),
    web_search: async ({ query, maxResults }) => search(query, maxResults),
  });

  if (out.success) {
    // out.content is the JSON-encoded result, ready as a tool_result block.
  } else {
    // out.code is 'unknown-tool' | 'input-validation' | 'execution'
  }
}
```

#### Python

```py
from anthropic import Anthropic
from generated.prompts import get_prompt

client = Anthropic()
brief = get_prompt("@acme/daily-brief")

res = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": brief.render("main", {"topic": topic})["prompt"]}],
    tools=brief.tool_defs(),
)

for block in res.content:
    if block.type != "tool_use":
        continue

    out = brief.dispatch_tool(block.name, block.input, {
        "fetch-page": lambda input: fetch_page(input.url),
        "web_search": lambda input: search(input.query, input.maxResults),
    })

    if out["success"]:
        ...  # out["content"] is the JSON-encoded result
    else:
        ...  # out["code"] is "unknown-tool" | "input-validation" | "execution"
```

The bindings object is typed from the pins, so omitting a tool — or giving one the wrong shape — is a compile error rather than a runtime surprise. A prompt that pins nothing has no `dispatchTool` at all.

### The trust boundary runs the other way

This is the part worth internalising, because it is the opposite of how prompt input and output work.

A tool's **arguments are written by the model**, so they get a *runtime* validator — the same machinery as a prompt's structured **output**. A tool's **result comes from your own code**, so it gets a *static* type — the same machinery as a prompt's **input**. Nothing at runtime checks that your implementation returns what its output schema claims; the type checker does.

> [!WARNING] dispatchTool is a pure function
>
> There is no loop, no SDK call and no retry inside it. Call it once per tool-use block the model
> emits, and keep the conversation loop in your own code where you can see it.

Only `ToolExecutionError` is reported back to the model as an `execution` failure. Anything else your implementation throws is treated as a bug: it propagates with its stack rather than being quietly handed to the model as a tool result.

#### TypeScript

```ts
import { ToolExecutionError } from './generated/prompts';

const fetchPage = async ({ url }: { url: string }) => {
  const res = await fetch(url);
  // The model can act on this; a thrown TypeError could not.
  if (!res.ok) throw new ToolExecutionError(`fetch failed with ${res.status}`);
  return { text: await res.text() };
};
```

#### Python

```py
from generated.prompts import ToolExecutionError

def fetch_page(input):
    res = httpx.get(input.url)
    # The model can act on this; an unhandled HTTPError could not.
    if res.status_code >= 400:
        raise ToolExecutionError(f"fetch failed with {res.status_code}")
    return {"text": res.text}
```

### Two behaviours worth knowing

**Draft pins produce a warning.** `sufleur install` warns `pins draft tool "x"` when a pinned tool version is still a draft. A draft contract can change without the prompt's version moving, so regenerate before trusting the output.

**Type names come from the registry ref, not the wire name.** The same contract pinned by two prompts is one generated type, and the alias only names the binding. If one prompt pins two *versions* of the same tool, every version's type takes a version suffix (`VendorWebSearchToolV1_2_0`), so no generated name depends on install order.

## What stays in the web app

Publishing a tool version, changing a tool's visibility, and deleting a tool are deliberately absent from the CLI — the same policy as prompts, datasets and collections. Publishing is the gate that unblocks publishing every dependent prompt; going private can strand published prompts in other workspaces; deletion is destructive and cross-workspace. Don't look for a command.

Next: [Prompts, versions & files](/docs/concepts) for the version lifecycle these pins ride on, or the [CLI reference](/docs/cli) for the rest of the authoring surface.
