Reference
Tool spec fields
A catalog is a JSON array of these objects. Three fields are required; the rest have defaults chosen to be the safe answer when you have not thought about them yet.
Full example
{
"name": "void_ticket",
"description": "Voids a ticket and releases its table. Use only when the user explicitly asks to void or cancel a specific ticket, and never to correct a single line item — use amend_ticket_line for that.",
"domain": "orders",
"is_write": true,
"risk": "high",
"requires_scope": true,
"chat_eligible": false,
"agent_eligible": true,
"deep_analysis_only": false,
"allow_with_image": false,
"parameters": {
"type": "object",
"properties": {
"ticket_id": { "type": "string", "description": "The ticket identifier, e.g. T-4417." }
},
"required": ["ticket_id"]
}
}
Fields
| Field | Type | Default | Required |
|---|---|---|---|
name | string | — | yes |
description | string | — | yes |
is_write | boolean | false | yes, in practice |
parameters | JSON Schema object | empty object schema | no |
domain | string | "general" | no |
risk | low | elevated | high | low | no |
requires_scope | boolean | false | no |
requires_customer | boolean | false | no |
precondition | object | none | no, writes only |
verify | object | none | no, writes only |
examples | object | none | no |
chat_eligible | boolean | true | no |
agent_eligible | boolean | true | no |
deep_analysis_only | boolean | false | no |
allow_with_image | boolean | !is_write | no |
Field names are snake_case on the wire.
name
Must match ^[a-z][a-z0-9_]{2,63}$ — lowercase, starting with a letter, digits and underscores
allowed, 3 to 64 characters. This is a provider constraint, not a house style; anything else is
rejected upstream, so registration rejects it first with a message you can act on.
Duplicate names are rejected case-insensitively.
Prefer verb-first names that read as what happens: get_open_tickets, void_ticket,
schedule_visit.
description
Required and non-empty. This is the only thing the model uses to decide whether this tool answers the question in front of it. It is a prompt, not documentation.
Write it to disambiguate against your other tools:
| ✗ | "Gets tickets." |
| ✓ | "Lists open tickets for a site, newest first. Use when the user asks what is outstanding or still open. Does not include tickets closed today — use get_ticket_history for those." |
Three things worth including: when to use it, when not to, and what it excludes.
is_write
The most consequential field in the file. It decides whether a call executes inline or is staged for confirmation.
Declared, never inferred. Tether does not guess from the name, the schema, or the description. If you omit it the tool is a read, which is the conservative failure: a write wrongly treated as a read executes without a confirmation, and a read wrongly treated as a write merely asks a question nobody needed.
See Reads and writes for what counts as a write, including the cases that do not look like one.
parameters
JSON Schema for the arguments object. Omitted, it defaults to:
{ "type": "object", "properties": { }, "required": [] }
Two things to know:
Descriptions inside the schema are read by the model. {"type":"integer","description":"Max rows. Default 20, max 100."} is worth writing.
Defaults in the schema are not applied by Tether. A parameter the model omits arrives absent. Apply defaults in your handler.
Never include a tenant, organization or account identifier as a parameter. That comes from the credential — a schema that invites the model to supply one is a bug. See Tenants and scope.
domain
A grouping label — "orders", "billing", "inventory" — used for routing and telemetry.
Defaults to "general".
It costs nothing to set and makes telemetry legible: selected_tool_domains on a turn tells you
which part of your product a question reached for.
risk
Only meaningful for writes. elevated and high both require acknowledgeHighRisk: true on
the confirm call; low does not.
A read declaring a risk above low is rejected at registration. It looks like a safety
measure and gates nothing, because risk is consulted only on the confirm path — which a read
never reaches. See Choosing risk levels.
requires_scope
Declares that the tool cannot answer meaningfully without a concrete scope — one site, one ledger, one region. The model then asks which one rather than guessing or answering across all of them.
Enforce it in your endpoint too: tenant.scope_id is null whenever the caller did not send
X-Tether-Scope-Id.
requires_customer
Declares that the capability acts for one end customer, such as a guest's own booking, and must
not run without a verified customer. Without one, Tether neither
calls it nor stages it, and the model is told to ask the person to sign in. With one, your endpoint
receives it as customer.id (or X-Tether-Customer-Id on an imported API) and enforces ownership.
It is declared, never inferred, like is_write, and a value that is not true or false is
refused rather than read as false. It may also be declared on an import, beside is_write. A
tool proxied to an MCP server (upstream: true) cannot require one, because the server is never
told who the customer is.
precondition
A fact in your system that must hold before a write may run, such as "this booking has not started" or "this order has not shipped". Declared on the write as a read to call, how the write's arguments become the read's, and a check over the read's JSON result:
{
"name": "cancel_booking",
"is_write": true,
"risk": "high",
"requires_customer": true,
"precondition": {
"tool": "get_booking_status",
"arguments": { "bookingId": { "from": "bookingId" } },
"check": { "path": "changeable", "equals": true }
}
}
| Key | What it is |
|---|---|
tool | A read in the same catalog. |
arguments | One entry per argument of the read: { "from": "<write argument>" } copies it, { "value": … } is a literal. Every argument the read requires must be mapped. |
check.path | A dot-separated path into the read's result, such as booking.status; a number indexes an array, as in lines.0.state. |
check.equals | The one allowed value, compared as JSON: true is not "true", 2 is 2.0, an object matches by its members in any order, and an array element by element. |
check.in | Several allowed values, instead of equals. A non-empty list. |
check.from | The name of a write argument whose value the path must equal, instead of equals or in. Used mostly by verify, to check a change against the value asked for. |
check.gt, gte, lt, lte | Instead of the above: the path's number must be more than, at least, less than or at most the other side, which is a number or { "from": "<write argument>" }. Both sides must be JSON numbers, or the check fails. |
A comparison is what an amount needs. A refund is eligible only while it is no more than the order can still refund, which no fixed value can say:
"precondition": {
"tool": "order_details",
"arguments": { "order_number": { "from": "order_number" } },
"check": { "path": "refundable_minor", "gte": { "from": "amount_minor" } }
}
A refund of more than is left is never staged, and the model is told both amounts.
Tether calls the read when the write is staged and again immediately before it is executed, through the ordinary read path: the same tenant and scope, the same verified customer the write is for, your endpoint's signature or your API's credential, and the read retry policy. A path that is missing fails the check.
- At staging, a check that does not hold stages nothing. Nobody is asked to approve a write
your system already refuses, and the tool result tells the model why, with your read's result
attached, so it can explain.
POST /v1/actionsanswers409with statusprecondition_failed. - At confirmation, state may have changed while the write waited. A check that no longer
holds settles the action as
precondition_failedand never calls the write; the confirm answers409with statusPreconditionFailed, and the audit recordsprecondition_failed. - If the read itself fails or does not answer, Tether fails closed: nothing is staged
(
precondition_error), or at confirmation the write is not called and the action stays pending (503,PreconditionError), so it can be confirmed again once your system answers.
The execute-once guarantee is unchanged, and your endpoint remains the final authority: it still
sees every confirmed write and can still refuse. A precondition only stops a write that is already
known to fail. It may be declared on an import, beside is_write, naming the read by its catalog
name.
verify
How Tether checks that a write did what it said. "Executed" means your endpoint answered without
an error; it does not mean the booking is cancelled. verify is declared on the write in exactly
the shape of a precondition: a read, how the write's arguments map onto it, and
a check over its result. A check may compare against the value the write asked for with
{ "path": "partySize", "from": "partySize" }.
{
"name": "cancel_booking",
"is_write": true,
"risk": "high",
"requires_customer": true,
"verify": {
"tool": "get_booking_status",
"arguments": { "bookingId": { "from": "bookingId" } },
"check": { "path": "status", "equals": "cancelled" }
}
}
After the write has executed and its result is recorded, Tether calls the read once, through the ordinary read path, for the same tenant, scope and verified customer, and waits at most a few seconds for it.
- The check holds: the audit records
verified, your endpoint receivesaction.verified, and the confirm response carriesverification: { "status": "verified", ... }. - The check does not hold, the read fails, or it does not answer in time: the audit records
unverifiedwith the reason, your endpoint receivesaction.unverifiedwith areason, and a person is handed the outcome: aconversation.handoffwith triggerunverified_outcome, naming the conversation that staged the write when there was one.
Verification only reads. It never calls the write again, it never changes the action's status,
which stays executed, and the confirm still answers 200 with status Executed. The
execute-once guarantee is unchanged: only the confirm that won the
claim verifies. It is validated with the same rules as a precondition, and may be declared on an
import beside is_write.
examples
Values your API's documentation gives for the arguments, by argument name, each a non-empty list:
"examples": { "bookingId": ["B101"] }
They are for a person trying the tool. The console's test step starts each required argument with the first example that fits its type, shows it in the field with where it came from, and runs nothing until the person clicks; a write still only stages, behind "I am using test data". An argument with no example can take a value from one of the person's own reads instead ("Use one from your data"), shown before it is used.
Documentation only. Tether never calls anything with them: they are not part of the tool
definition the model is offered, no argument is filled from them, and a precondition or verify
never reads them. A value a document calls an example is somebody's guess about data, and a tool
called with it would act on whatever record carries it.
The OpenAPI import writes them from the document: a parameter's own
example and examples, then its schema's example, examples and default; for a JSON request
body, the media type's example and examples, member by member, then each property's. At most
five an argument. A schema's own example also stays in parameters, where the model reads it, as
the document wrote it; put an example on the parameter or the media type to keep it out of the
model's view. An examples that is not an object mapping the tool's own arguments to a non-empty
list is refused.
chat_eligible and agent_eligible
Which surfaces may be offered this tool. Both default to true.
Both false is rejected — a tool reachable from nowhere is dead weight you think is live.
A common shape for a destructive write is chat_eligible: false, agent_eligible: true: it never
appears in ordinary conversation, and is reachable only on a turn that explicitly opted in with
enableWriteActions.
deep_analysis_only
Restricts the tool to turns requesting capabilityMode: "Deep", which is only available on
plans that allow it. For expensive or slow tools that should not be reached for casually.
allow_with_image
Whether the tool may be offered on a turn carrying an image. Defaults to the inverse of
is_write.
Validation summary
| Rejected when | Message fragment |
|---|---|
| No name | tool(s) have no name |
| Name breaks the pattern | names must match ^[a-z][a-z0-9_]{2,63}$ |
Named tether_handoff, which is reserved | use a name reserved for a capability the engine provides itself |
| Empty description | no description, so the model would call them at random |
| Duplicate name | declared more than once |
| Both surfaces false | registered but offered on no surface |
| Read with risk above low | declared a risk level but not declared a write |
requires_customer not a boolean | declared requiresCustomer as something other than true or false |
requires_customer on a proxied tool | require a verified customer but are proxied to an MCP server |
| A precondition that is not the shape above, or has an unknown key | have a precondition that is not a read's tool name, arguments that are each {from} or {value}, and a check of a path with equals, a non-empty in, from a write argument, or gt, gte, lt or lte against a number or {from} a write argument |
| A precondition on a read | declared a precondition but not declared a write |
| A precondition naming a tool not in the catalog | have a precondition naming a read that is not in the catalog |
| A precondition naming a write | have a precondition naming a write |
| Mapping an argument the read does not have | have a precondition mapping an argument the read does not have |
| Mapping from an argument the write does not have | have a precondition mapping from an argument the write does not have |
| A required argument of the read left unmapped | have a precondition that leaves a required argument of the read unmapped |
A verify breaking any of the rules above (missing read, a write, unknown or unmapped argument, a check from an argument the write lacks, malformed) | the same message with verify in place of precondition, such as have a verify naming a read that is not in the catalog |
A verify on a read | declared a verify but not declared a write |
An http binding's outcomes that is not statuses from 400 to 599 mapped to refused or indeterminate | have http outcomes that are not an object mapping a status from 400 to 599 to refused or indeterminate |
outcomes on a read | declared http outcomes but not declared a write |
A 5xx declared refused (why) | declare a 5xx status refused, but a server error after the request was sent does not prove the write was not applied |
examples that is not an object mapping the tool's own arguments to a non-empty list | have examples that are not an object mapping their own arguments to a non-empty list of values |
All problems are reported in a single message. Run the same checks in CI with
POST /v1/tools/validate — see Validating your catalog in CI.