API reference
Tools
Three endpoints govern the entire universe of what a model can reach on your behalf. Registering a catalog is the moment you declare which of your capabilities mutate state.
Read the current catalog
GET /v1/tools
{
"version": 3,
"tools": [ { "name": "get_open_tickets", "description": "…", "is_write": false, "parameters": { } } ],
"toolCount": 12,
"writeToolCount": 4,
"registeredAt": "2026-08-20T11:04:52Z",
"import": {
"baseUrl": "https://api.yourcompany.com",
"toolCount": 12,
"importedAt": "2026-08-20T11:02:10Z",
"source": { "kind": "repository", "commit": "4e1c9a…", "path": "docs/api/openapi.json", "dirty": false }
}
}
tools is the exact JSON you registered, stored whole and returned unmodified. A tenant that
has never registered gets version: 0 and an empty array rather than a 404. import is the
last successful import: where its operations are called, how many it found, when,
and its source, or null when the tenant has imported nothing. The credential is never shown.
Validate without registering
POST /v1/tools/validate
Identical validation to PUT, with no side effect. This is what belongs in CI.
curl -X POST https://api.trytether.ge/v1/tools/validate \
-H "X-Api-Key: $TETHER_SECRET_KEY" \
-H "Content-Type: application/json" \
--data-binary @tools.json
{
"valid": true,
"toolCount": 2,
"writeToolCount": 1,
"tools": [
{ "name": "get_open_tickets", "domain": "orders", "isWrite": false, "risk": "low",
"requiresScope": true, "surfaces": { "chat": true, "agent": true } },
{ "name": "void_ticket", "domain": "orders", "isWrite": true, "risk": "high",
"requiresScope": false, "surfaces": { "chat": false, "agent": true } }
]
}
On failure, 400:
{
"valid": false,
"error": "Tool catalog is invalid — names must match ^[a-z][a-z0-9_]{2,63}$ (the provider rejects anything else): VoidTicket; no description, so the model would call them at random: get_stock."
}
Every problem is reported in one message rather than one at a time.
Register a catalog
PUT /v1/tools
The body is a JSON array of tool specs. It replaces the catalog entirely — there is no partial update, and a tool absent from the array is a tool that no longer exists.
curl -X PUT https://api.trytether.ge/v1/tools \
-H "X-Api-Key: $TETHER_SECRET_KEY" \
-H "Content-Type: application/json" \
--data-binary @tools.json
{ "registered": true, "version": 4, "toolCount": 12, "writeToolCount": 4 }
Rejected catalogs return 400 with the same error string as validation, and
"registered": false. The previous catalog stays live — a bad deploy of your catalog does
not leave the tenant with no tools.
Importing
POST /v1/tools/import
Turns an OpenAPI document into a catalog whose calls go straight to your API, so you do not host a tool endpoint. Nothing is registered by this call.
| Field | Notes |
|---|---|
document | Required. The OpenAPI document itself, as a JSON object. A url is not accepted; the CLI reads a file for you. |
baseUrl | Required. Where the operations are called. It passes the same address checks as a tool endpoint. |
operations | Your declarations as an object keyed by operation name, each with is_write and, for a write, risk: { "cancel_order": { "is_write": true, "risk": "high" } }, or { "skip": true } to leave the operation out. A declaration may also carry description, parameters, domain, name, requires_customer and, on a write, a precondition, a verify and outcomes, which are copied into the catalog as written. |
skipUndeclared | true leaves out every operation operations does not name. Optional; without it, an undeclared operation refuses the import. |
credential | Sent as Authorization: Bearer … on every call to baseUrl. Optional. |
source | Optional. Where the document came from: { "kind": "repository", "commit": "<git commit id>", "path": "docs/api/openapi.json", "dirty": false }, with the same rules as a knowledge document's. It is echoed on the draft refusal and kept with a successful import, shown as import.source on GET /v1/tools. A malformed one refuses the import, naming the field. tether publish sends it. |
userToken | ignore, prefer or require: whether a call uses the signed-in person's own token instead of credential. See Calling your API as the signed-in user. Optional; a re-import without it keeps the previous mode, and a first import starts at ignore. |
It takes two calls. The first has no operations and is refused on purpose, with a list of what
it found. Each entry carries suggested_is_write from the HTTP verb; nothing reads it.
{
"imported": false,
"error": "Import refused — 2 operation(s) do not declare is_write, …",
"operations": [
{ "name": "cancel_order", "method": "POST", "path": "/orders/{orderId}/cancel", "description": "…", "parameters": { }, "suggested_is_write": true }
],
"source": null
}
A repository's CI key (tci_) may make this first call only,
with a source: Tether records the draft, and the same call with operations, a credential or
skipUndeclared is 403 insufficient_scope with reason ci_draft_only. Declaring is a
person's.
Send the second call with operations filled in and credential set. It answers imported: true
with the catalog, every entry carrying an http binding (and, where the document gives example
values, examples, documentation the model never sees), the
operations it skipped, and warnings about descriptions the model is likely to misread. Review it, then register it with
PUT /v1/tools:
{
"imported": true,
"baseUrl": "https://api.yourcompany.com",
"userToken": "ignore",
"toolCount": 2,
"writeToolCount": 1,
"catalog": [ "…" ],
"skipped": [ { "name": "admin_list_all", "method": "GET", "path": "/admin/all" } ],
"warnings": [
{ "operation": "search_shops", "parameter": "carIds", "code": "optional_number",
"message": "An optional number the model tends to fill, often with 0, which narrows or empties the results. Describe when to leave it out, or omit it." }
]
}
Choosing what to import
Most documents describe more than an assistant should touch: sign-in, uploads, administration. Leaving an operation out is a third answer beside read and write, and never a read. An operation that is left out is not in the catalog at all, so it cannot be called.
{ "skip": true }leaves one operation out. It needs no description and no valid name, and it cannot be combined withis_write."skipUndeclared": trueleaves out every operationoperationsdoes not name. Declare only what the assistant may use.- A declaration that names an operation the document does not have is refused, so a typo is an error rather than a missing capability.
- Skipping everything is refused.
Describing operations and inputs
The text the model reads decides whether it calls the right operation with the right inputs. You can write it in the declaration instead of editing your OpenAPI document:
"search_shops_near": {
"is_write": false,
"description": "Find repair shops near a point, nearest first. Use it whenever the visitor names a place.",
"parameters": {
"lat": { "required": true, "description": "Latitude of the place the visitor named." },
"serviceId": { "description": "A child category id from list_services. Leave it out if no service was named." },
"carIds": { "omit": true }
}
}
descriptionreplaces the document's summary and description. An operation the document left undescribed imports when the declaration gives one.parameters.<name>.descriptionreplaces that input's description;required: truemakes the model supply it;omit: truetakes an optional input away from the model.- An input the document requires cannot be omitted or made optional, and an input the operation does not have is refused.
warnings never block an import. Their codes are short_description (under 40 characters),
optional_number (an optional, undescribed number or list of numbers), undescribed_id (an id
with no word on where it comes from) and undescribed_optional. See
Describing your API for the assistant.
The credential is stored when you send it on this second call, not when you register, and can be
changed later without importing again: see Changing the connection. If
your API later answers 401 or 403, the tool result says which credential was sent: the
signed-in person's own token, which has probably expired, the stored
credential, which you should check, or none, with how to store one. It never repeats either.
A write in an imported catalog stages and waits for confirmation exactly as a native one does; your API is not called until it is confirmed.
When your API refuses a confirmed write
Once confirmed, the write is sent once. If your API answers with an error, Tether reads the status:
| Your answer | What happens to the action |
|---|---|
A 4xx other than 408 and 429 | Refused. You did not apply it. It settles as failed with your reason, and is never retried |
408 or 429 | Not applied; it goes back to pending, so a person can confirm it again |
A 5xx, a timeout, or a lost connection after sending | Indeterminate. It may have been applied, so it is held for reconcile |
The reason is read from your error body: {"error": {"code", "message" or "detail"}}, {"error": "<code>", "message"}, RFC 7807's title and detail, the first of errors, {"code", "message"}, or plain text. The confirm answers 409 with status ProviderRefused and that reason,
the audit records provider_refused, and your events webhook receives action.provider_refused.
Nothing is verified, because nothing ran.
If one of your 4xx statuses can mean the request is still running, declare it on the operation,
so the write is reconciled instead of refused:
{ "cancel_order": { "is_write": true, "risk": "high", "outcomes": { "409": "indeterminate" } } }
outcomes is copied into the tool's http binding. Each key is a status from 400 to 599, each
value refused or indeterminate. A 5xx cannot be declared refused, because a server error
after the request was sent does not prove the write was not applied.
Changing the connection
PATCH /v1/tools/api
Changes the stored credential, the user-token mode, or both, on the API you imported, without
importing again. Secret key only; any other key is refused 403 insufficient_scope.
curl -X PATCH https://api.trytether.ge/v1/tools/api \
-H "X-Api-Key: $TETHER_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "credential": "sk_live_new", "userToken": "prefer" }'
| Field | Notes |
|---|---|
credential | A string. Omitted, the stored credential is unchanged; "" clears it. At most 4000 characters |
userToken | ignore, prefer or require. Omitted, the mode is unchanged. See Calling your API as the signed-in user |
Send at least one of them, and nothing else: an unknown field, an empty body, a credential that
is not a string or a userToken outside the three modes is refused with 400 and
{ "error": "…" }. The answer never contains the credential:
{ "baseUrl": "https://api.yourcompany.com", "userToken": "prefer", "credentialStored": true }
When nothing has been imported, it answers 404, and you import first with
POST /v1/tools/import. The change applies from the next request; a turn already
running keeps the connection it started with.
Versioning
Each registration writes a new row and marks the previous one not-current. Nothing is overwritten.
Two reasons, both operational: a turn already choosing tools keeps the catalog it started with rather than having it change underneath, and a customer whose assistant got worse can see exactly what the previous version contained.
The catalog is read once per request and cached for that request's lifetime.
Validation rules
| Rejected when | Message fragment |
|---|---|
| A tool has no name | tool(s) have no name |
| A name breaks the pattern | names must match ^[a-z][a-z0-9_]{2,63}$ |
A tool is named tether_handoff, the engine's own handoff capability | use a name reserved for a capability the engine provides itself |
| A description is empty | no description, so the model would call them at random |
| Two tools share a name | declared more than once |
chat_eligible and agent_eligible are both false | registered but offered on no surface |
A read declares a risk above low | declared a risk level but not declared a write, so nothing would gate them |
| A precondition names a missing read, maps an argument either side lacks, or is malformed | have a precondition …; every case is in the tool spec reference |
| A verify breaks the same rules | have a verify …, or declared a verify but not declared a write |
An http binding's outcomes is not statuses from 400 to 599 mapped to refused or indeterminate, is on a read, or calls a 5xx refused | have http outcomes that are not …, declared http outcomes but not declared a write, declare a 5xx status refused |
Name comparison for duplicates is case-insensitive, so get_tickets and Get_Tickets collide —
and the second would have been rejected by the pattern anyway.
Fields
Every field of a tool spec is documented in the tool spec reference. The minimum viable tool is three fields:
{
"name": "get_open_tickets",
"description": "Lists open tickets for a site, newest first. Use when the user asks what is outstanding or still open.",
"is_write": false
}
parameters defaults to an empty object schema. Everything else has a default, and
is_write defaults to false — conservative, because a tool wrongly treated as a read is
merely useless, while the reverse executes without a confirmation.
An unparseable stored catalog
If a stored catalog somehow fails to parse at request time, the tenant is served zero tools for that request and the failure is logged. The assistant degrades to answering from context alone rather than the request failing.
That is a bad day, not an outage — but it is silent from the caller's side, so the CI check is the place to catch it.
Related
- Tools — the concepts behind the fields
- Tool spec reference
- Validating your catalog in CI