Tether
DocsGitHub

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

FieldTypeNotes
toolstringThe registered tool name. Always one from your current catalog.
argumentsobjectModel-produced, schema-shaped, not validated against your schema.
tenant.organization_idintegerThe authenticated tenant. Scope every query by this.
tenant.scope_idinteger or nullThe narrower scope, from X-Tether-Scope-Id. Null when unset.
tenant.user_idintegerThe acting user, from X-Tether-User-Id. 0 when unattributed.
customerobject or nullThe end customer Tether verified from your customer token. null when the turn had none.
customer.idstringYour own id for that customer. On a confirmed write, the customer it was staged for.
is_writebooleanAs declared in your catalog.
modestring"execute" on a confirmed write. Absent otherwise.
request_idstring32 hex characters. Also sent as X-Tether-Idempotency-Key.

Headers

HeaderPurpose
X-Tether-Signaturesha256= + hex HMAC over {timestamp}.{body}
X-Tether-TimestampUnix seconds, and part of the signed payload
X-Tether-Idempotency-KeySame value as request_id
X-Tether-Customer-IdOnly on an imported API, which has no envelope: the verified customer.id
Content-Typeapplication/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.