The tool endpoint
Request envelope
One shape for every tool call, on both paths. The only difference between a read and a confirmed write is two fields. A second, smaller shape, a lifecycle event, arrives at the same URL and must never be run as a tool. Both say which verified customer, if any, the call is for.
A read
{
"tool": "get_open_tickets",
"arguments": { "limit": 20 },
"tenant": {
"organization_id": 42,
"scope_id": 9,
"user_id": 431
},
"customer": { "id": "G1" },
"is_write": false,
"request_id": "6f1c9a0e4b7d4c2f8a3e5d1b7c9f0a2e"
}
A confirmed write
{
"tool": "void_ticket",
"arguments": { "ticket_id": "T-4417" },
"tenant": {
"organization_id": 42,
"scope_id": 9,
"user_id": 431
},
"customer": { "id": "G1" },
"is_write": true,
"mode": "execute",
"request_id": "9c1e7b6a3f424a519d0e6b2c8f4a1d33"
}
mode is present only on a confirmed write. Its presence is not the security boundary — the
signature and your own permission checks are — but it is a useful assertion: if you see
"mode": "execute", a human confirmed this through /v1/actions/{id}/confirm.
Fields
| Field | Type | Notes |
|---|---|---|
tool | string | The registered tool name. Always one from your current catalog. |
arguments | object | Model-produced, schema-shaped, not validated against your schema. |
tenant.organization_id | integer | The authenticated tenant. Scope every query by this. |
tenant.scope_id | integer or null | The narrower scope, from X-Tether-Scope-Id. Null when unset. |
tenant.user_id | integer | The acting user, from X-Tether-User-Id. 0 when unattributed. |
customer | object or null | The end customer Tether verified from your customer token. null when the turn had none. |
customer.id | string | Your own id for that customer. On a confirmed write, the customer it was staged for. |
is_write | boolean | As declared in your catalog. |
mode | string | "execute" on a confirmed write. Absent otherwise. |
request_id | string | 32 hex characters. Also sent as X-Tether-Idempotency-Key. |
Headers
| Header | Purpose |
|---|---|
X-Tether-Signature | sha256= + hex HMAC over {timestamp}.{body} |
X-Tether-Timestamp | Unix seconds, and part of the signed payload |
X-Tether-Idempotency-Key | Same value as request_id |
X-Tether-Customer-Id | Only on an imported API, which has no envelope: the verified customer.id |
Content-Type | application/json; charset=utf-8 |
Lifecycle events arrive here too
The same URL, signed the same way, also receives notifications about the writes you have staged, unless you set a separate events webhook, which then receives them instead:
{
"event": "action.pending",
"action_id": "9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33",
"tool": "void_ticket",
"tenant": { "organization_id": 42, "scope_id": null, "user_id": 0 },
"customer": { "id": "G1" },
"request_id": "6ad75e9b73bd464ba811bf88e449495a",
"occurred_at": "2026-09-30T09:13:42.5120000Z",
"summary": "void ticket — awaiting confirmation.",
"expires_at": "2026-09-30T10:13:42Z"
}
event is one of action.pending, action.approved, action.rejected, action.expired,
action.executed, action.verified, action.unverified, action.precondition_failed and
action.provider_refused.
action.pending also carries summary and expires_at, action.rejected carries the reason, and
action.unverified carries a reason saying what the write's
verify read found or why it could not be checked; those two
follow action.executed for a write that declares verify. action.precondition_failed carries a
reason saying what the write's precondition read found
at confirmation: the action was settled and the write was never called. action.provider_refused
reaches a tenant whose write went to its imported API,
which refused it without applying it; its reason carries the API's status, code and words. action.expired is sent
once, by whichever of a confirm and the scheduled expiry sweep noticed the window had passed.
Every event carries occurred_at, when it was raised, and its request_id is the event's id.
Verify the signature as you would for a call, then, when event is present, notify whoever needs to
know and answer 2xx. An event is sent after the action has moved, never inside the request that
moved it, and is retried until your receiver answers 2xx or the attempts run out, so the same
event can arrive more than once: deduplicate on request_id, which is the same on every attempt.
See Delivery.
The Node, Python and .NET SDKs do this for you: they hand events to an
optional onEvent and acknowledge and drop them when you have none.
A conversation was handed to a person
conversation.handoff arrives the same way when a turn is handed to a person:
{
"event": "conversation.handoff",
"session_id": "0f2c8b41-6d3e-4a19-9c77-1b2a3e4f5d60",
"trigger": "customer_request",
"language": "ka",
"tenant": { "organization_id": 42, "scope_id": null, "user_id": 0 },
"customer": { "id": "G1" },
"request_id": "b52e0c6f1d7a4e9a9f3c2d1e0a4b5c6d",
"occurred_at": "2026-09-30T09:20:03.0410000Z"
}
It is about a conversation, not an action, so it has no action_id, no tool and no
arguments. trigger is customer_request, agent, or unverified_outcome (an executed
write whose outcome could not be verified); an agent handoff carries the model's reason, and an
unverified_outcome one carries a reason naming the action, a session_id that is null when the
write was not staged in a conversation, and a null language. Acknowledge it and run nothing.
It is sent without the turn waiting for it.
Events go to your events webhook when you set one, and otherwise to the
tool endpoint. A tenant whose tools are all imported or proxied has no tool
endpoint, so it sets the events webhook to receive them; with neither set it receives none, and
nothing is lost, but nothing tells you a write is waiting. Every delivery attempt, delivered or not,
is listed at GET /v1/events/deliveries.
What you may trust, and what you may not
Concretely, this is the mistake to avoid:
// WRONG — the model can put any id here.
const rows = await db.tickets.findMany({ where: { orgId: args.organization_id } })
// RIGHT — the tenant block is the only identity that means anything.
const rows = await db.tickets.findMany({ where: { orgId: tenant.organization_id } })
A tool whose schema contains an organization_id parameter is a bug in the catalog, not a
feature. If a tool needs to know which site, that is tenant.scope_id.
The customer, and ownership
customer comes from a token your own server signed after your own sign-in, verified by Tether
before the turn starts. It is never read from the conversation, and a customer_id the model puts
in arguments does not change it. Enforce ownership with it:
// WRONG — the model can name any guest.
if (booking.guestId !== args.guest_id) return res.status(403).json({ error: 'not_owner' })
// RIGHT — the verified customer is the only one asking.
if (!customer || booking.guestId !== customer.id) return res.status(403).json({ error: 'not_owner' })
Declare requires_customer: true on a capability that acts for one customer, and Tether neither
calls nor stages it without a verified one. See Verified customers.
Scope, when it is absent
scope_id is null whenever the caller did not send X-Tether-Scope-Id. Two reasonable
responses:
Answer across the tenant. Correct for a tool like get_monthly_spend, where the org-wide
figure is meaningful.
Refuse. Correct for a tool like get_open_tickets, where an answer spanning every site is
misleading. Register it requires_scope: true so the model knows it needs a site first, and
still guard it in your endpoint:
if (!tenant.scope_id) {
return res.json({ error: 'This tool needs a specific site. Ask the user which one.' })
}
The model reads that error and asks the question. That is the intended behaviour, not a failure.
Argument defaults
Tether does not fill in defaults from your JSON Schema. A limit documented as "default 20"
arrives absent when the model does not set it, so apply the default in your handler.
Encoding
The body is UTF-8 JSON. Because the signature covers the exact bytes, your framework must give you the raw body before any parsing or re-serialization — see Verifying signatures.