SDKs
Node SDK
Node 18+, zero dependencies — node:crypto and nothing else. It also carries the
tether CLI, which works against an endpoint in any language.
The endpoint
const express = require('express')
const tether = require('tether')
const app = express()
const tools = {
get_order_status: {
description: 'Look up one order by its number and return its fulfilment state.',
domain: 'orders',
schema: {
type: 'object',
properties: { order_id: { type: 'string', description: 'The order number.' } },
required: ['order_id'],
},
handler: async ({ order_id }, ctx) => db.orders.status(ctx.tenant.scopeId, order_id),
},
cancel_order: {
description: 'Cancel an order that has not yet been dispatched.',
domain: 'orders',
write: true,
risk: 'elevated',
schema: {
type: 'object',
properties: { order_id: { type: 'string', description: 'The order to cancel.' } },
required: ['order_id'],
},
handler: async ({ order_id }) => db.orders.cancel(order_id),
},
}
app.post('/tether', tether.createHandler({ secret: process.env.TETHER_SECRET, tools }))
A tool is a definition object carrying handler, or a bare function (arguments, ctx). Use the
first form: buildCatalog reads the same object, so the schema you register cannot drift from
the function serving it.
write: true is the only thing that makes a write a write. There is no inference from the name.
The context argument
handler: async (args, ctx) => {
ctx.tenant.organizationId // who this turn belongs to
ctx.tenant.scopeId // the site, ledger or region, when the tenant has several
ctx.tenant.userId
ctx.tool // the tool being called
ctx.isWrite // as you declared it
ctx.mode // "execute" on a confirmed write, null otherwise
ctx.requestId // also the idempotency key
ctx.customer // { id } of the verified end customer, or null
}
Scope every query by ctx.tenant, never by anything in args. The tenant came from the
credential and cannot be influenced by the model; an argument is whatever the model decided to
send.
Enforce ownership with the verified customer. ctx.customer is the end customer Tether verified from the
token your server signed (Verified customers), or null for an
anonymous turn. Refuse a booking, order or account it does not own, whatever the arguments say: a
customer_id in the arguments is something the model wrote. Declare requiresCustomer: true on a tool that acts for
one customer and Tether neither calls nor stages it without one. Your host mints the token with mintCustomerToken(key, { tenant, customerId, ttlSeconds }).
Registering the catalog
const { buildCatalog } = require('tether')
const catalog = buildCatalog(tools)
await fetch(`${process.env.TETHER_URL}/v1/tools`, {
method: 'PUT',
headers: { 'X-Api-Key': process.env.TETHER_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(catalog),
})
buildCatalog throws with the engine's own message when a definition is incomplete, so a broken
catalog fails at your build rather than at registration. tether register does the same thing
with one command.
Lifecycle events
The same endpoint (or your events webhook, which a tenant that only imported
its API serves with this same handler) receives action lifecycle notifications — action.pending,
action.approved, action.rejected, action.expired, action.executed, action.verified,
action.unverified, action.precondition_failed, action.provider_refused (an imported API
refused a confirmed write) — and
conversation.handoff when a conversation is handed to a person. They
carry no arguments and never mutate anything, and the signature check gates them exactly as it
gates a tool call. EVENT_TYPES lists them all.
tether.createHandler({
secret: process.env.TETHER_SECRET,
tools,
onEvent: async (event, ctx) => {
if (event.event === 'action.pending') await notifyApprovers(event.action_id)
if (event.event === 'conversation.handoff') await openTicket(event.session_id, ctx.customer)
},
})
Omit onEvent and the event is acknowledged and dropped. You do not have to write a handler to
stop it erroring.
Options
secret | your tenant's signing secret. Required |
tools | the tool map. Required |
toleranceSeconds | timestamp tolerance, default 300 |
store | idempotency store, { get, set }. Default is per-process memory |
onEvent | called for an event (any of EVENT_TYPES) rather than a tool call |
now | unix-seconds clock, for tests |
What it returns
| Situation | Status | error |
|---|---|---|
| Signature does not match | 401 | invalid_signature |
| Timestamp outside tolerance | 401 | stale_timestamp |
| Write without execute mode | 400 | execute_mode_required |
| No handler for that tool | 400 | unknown_tool |
| Body is not JSON | 400 | invalid_body |
| Your handler threw | 500 | tool_failed, with the message |
Replayed request_id | 200 | the first result, X-Tether-Replayed: true |
A failed call is not cached. The retry reaches your handler.
Related
- SDKs — what all three share, and where they differ
- The tether CLI — ships in this package
- Validating your catalog in CI
- Testing with mock mode — exercise it with no provider key