Tether
DocsGitHub

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

secretyour tenant's signing secret. Required
toolsthe tool map. Required
toleranceSecondstimestamp tolerance, default 300
storeidempotency store, { get, set }. Default is per-process memory
onEventcalled for an event (any of EVENT_TYPES) rather than a tool call
nowunix-seconds clock, for tests

What it returns

SituationStatuserror
Signature does not match401invalid_signature
Timestamp outside tolerance401stale_timestamp
Write without execute mode400execute_mode_required
No handler for that tool400unknown_tool
Body is not JSON400invalid_body
Your handler threw500tool_failed, with the message
Replayed request_id200the first result, X-Tether-Replayed: true

A failed call is not cached. The retry reaches your handler.