Tether
DocsGitHub

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

FieldTypeDefaultRequired
namestring—yes
descriptionstring—yes
is_writebooleanfalseyes, in practice
parametersJSON Schema objectempty object schemano
domainstring"general"no
risklow | elevated | highlowno
requires_scopebooleanfalseno
requires_customerbooleanfalseno
preconditionobjectnoneno, writes only
verifyobjectnoneno, writes only
examplesobjectnoneno
chat_eligiblebooleantrueno
agent_eligiblebooleantrueno
deep_analysis_onlybooleanfalseno
allow_with_imageboolean!is_writeno

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 }
  }
}
KeyWhat it is
toolA read in the same catalog.
argumentsOne entry per argument of the read: { "from": "<write argument>" } copies it, { "value": … } is a literal. Every argument the read requires must be mapped.
check.pathA dot-separated path into the read's result, such as booking.status; a number indexes an array, as in lines.0.state.
check.equalsThe 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.inSeveral allowed values, instead of equals. A non-empty list.
check.fromThe 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, lteInstead 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/actions answers 409 with status precondition_failed.
  • At confirmation, state may have changed while the write waited. A check that no longer holds settles the action as precondition_failed and never calls the write; the confirm answers 409 with status PreconditionFailed, and the audit records precondition_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 receives action.verified, and the confirm response carries verification: { "status": "verified", ... }.
  • The check does not hold, the read fails, or it does not answer in time: the audit records unverified with the reason, your endpoint receives action.unverified with a reason, and a person is handed the outcome: a conversation.handoff with trigger unverified_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 whenMessage fragment
No nametool(s) have no name
Name breaks the patternnames must match ^[a-z][a-z0-9_]{2,63}$
Named tether_handoff, which is reserveduse a name reserved for a capability the engine provides itself
Empty descriptionno description, so the model would call them at random
Duplicate namedeclared more than once
Both surfaces falseregistered but offered on no surface
Read with risk above lowdeclared a risk level but not declared a write
requires_customer not a booleandeclared requiresCustomer as something other than true or false
requires_customer on a proxied toolrequire a verified customer but are proxied to an MCP server
A precondition that is not the shape above, or has an unknown keyhave 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 readdeclared a precondition but not declared a write
A precondition naming a tool not in the cataloghave a precondition naming a read that is not in the catalog
A precondition naming a writehave a precondition naming a write
Mapping an argument the read does not havehave a precondition mapping an argument the read does not have
Mapping from an argument the write does not havehave a precondition mapping from an argument the write does not have
A required argument of the read left unmappedhave 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 readdeclared a verify but not declared a write
An http binding's outcomes that is not statuses from 400 to 599 mapped to refused or indeterminatehave http outcomes that are not an object mapping a status from 400 to 599 to refused or indeterminate
outcomes on a readdeclared 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 listhave 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.