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.
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}$.
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:
sufleur version tools add @acme/daily-brief@draft @vendor/web-search@^1.2.0 --as web_searchA few things follow from that shape:
- A pin is a version, not a range. The constraint (
^1.2.0,*,1.2.0, ordraft) 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. --assets 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
renameandremoveidentify 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.
sufleur tool create @acme/fetch-page --description "Fetches a URL and returns its text"
sufleur tool dump @acme/fetch-page@draft --to ./tooldump 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:
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.yamlThe 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.
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. |
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'
}
}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.
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.
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() };
};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 for the version lifecycle these pins ride on, or the CLI reference for the rest of the authoring surface.