Tether
DocsGitHub

Quickstart · door one

Bring your AI

You have an agent, tools and a UI already. None of that changes. Tether goes in front of the tools, so reads carry tenant scoping and an audit trail, and writes stop and wait for someone.

You keep your model, your provider key and your prompts. Tether never sees your provider bill on this door, and tool results do not pass through it to a model — see Security.

Before you start

Path C below uses the tether CLI, which is installed from the SDK source rather than a registry. Everything else on this page is plain HTTP.

Sign up. Where signup is open it returns your credentials, and one paste sets up your shell:

export TETHER_URL="https://api.trytether.ge"
curl -X POST $TETHER_URL/v1/signup \
  -H "Content-Type: application/json" \
  -d '{ "email": "dev@example.com", "name": "Example Co" }'

The response carries exportLines: your TETHER_URL, TETHER_SECRET_KEY and TETHER_SECRET (the signing secret for your tool endpoint), ready to paste. They are shown once. A signup tenant is a capped development tenant; see Signup.

Where signup answers 404 signup_disabled, ask for a pilot. The operator issues your tenant the same secret key and signing secret, plus a restricted agent key for runtime calls:

export TETHER_URL="https://api.trytether.ge"
export TETHER_SECRET_KEY="tth_…"
export TETHER_AGENT_KEY="tag_…"

Issue the agent credential yourself with your secret key:

curl -X POST $TETHER_URL/v1/account/keys \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "agent", "label": "production-agent" }'

See Account for rotating it and for what to do if it leaks. Do not hand your secret key to an agent you do not control; give it the tag_ key.

Three paths, easiest first

Take the first one that describes you. They end in the same place: a registered catalog Tether governs.

You havePath
AAn MCP server your agent already talks toProxy it
BTool definitions or an OpenAPI documentImport them
CNeither, or you want handlers in codeRegister a catalog

A. Proxy an MCP server you already run

The lowest-friction path. Tether lists your server's tools, you say which ones write, and your agent points at Tether instead.

curl -X PUT $TETHER_URL/v1/upstreams \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "mcp", "url": "https://tools.yourcompany.com/mcp", "token": "…" }'

Tether connects, lists the tools, and stores them as an unmarked draft. Nothing is proxied yet.

{
  "connected": true,
  "url": "https://tools.yourcompany.com/mcp",
  "toolCount": 9,
  "tools": [
    {
      "name": "refund_order",
      "description": "Refunds one order to the original card. Use only when the user names an order.",
      "parameters": { "type": "object", "properties": { "order_id": { "type": "string" } } },
      "upstream": true,
      "proxied": false,
      "suggested_is_write": true,
      "suggested_risk": "elevated"
    }
  ],
  "note": "Nothing is proxied until each tool declares is_write (and a risk, for writes)."
}

GET /v1/upstreams returns the same draft later.

Mark the writes

A drafted tool carries suggested_is_write and suggested_risk, prefilled from the server's own readOnlyHint and destructiveHint annotations where they exist. They are suggestions. Tether will not proxy a tool until you say what it is.

curl -X PUT $TETHER_URL/v1/tools \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    { "name": "search_orders", "description": "…", "is_write": false, "upstream": true },
    { "name": "refund_order",  "description": "…", "is_write": true, "risk": "high", "upstream": true }
  ]'

Each tool needs a description, and the draft above already has the server's own: copy it, or write a better one. A missing description is refused, and a placeholder such as … is accepted but leaves the model choosing tools at random.

"upstream": true is what sends the call to your MCP server. Without it the tool is registered as one of your own, its calls go to your tool endpoint, and the registration answers with a warning naming it. The schema comes from the server, so you do not repeat it.

is_write is declared, never inferred — not from the name, not from the HTTP verb, not from an annotation. A tool left unmarked is not proxied, which is the safe direction to fail in. See Tools.

Point your agent at Tether

https://api.trytether.ge/mcp

Same protocol, same tool names. Configure the connection with this header:

X-Api-Key: tag_…

Reads pass through scoped and audited. Writes stage and wait: your agent gets a pending action rather than a result, reports it to the user, and stops. The agent connection cannot call confirm_action. A trusted application confirms through the Actions API, the approval inbox, or your own approval UI backed by the tenant secret. See The MCP endpoint.

That is the whole integration.


B. Import what you already have

Tool definitions in OpenAI or Anthropic function-calling format are already JSON Schema, and so is an OpenAPI document.

With the CLI, from a directory where you ran tether init:

tether import openapi.json --api https://api.yourcompany.com

That run is refused on purpose and writes tether.declarations.json, one entry per operation with is_write left null. Set is_write to true or false for each, and a risk for each write. Then import again with the key for your own API, and register what it writes:

TETHER_API_CREDENTIAL="…" tether import openapi.json --api https://api.yourcompany.com \
  --declare tether.declarations.json

The import points tools in tether.config.json at ./tether.catalog.json for you, unless it already names a tools file you wrote. Then tether register. Reads go straight to your API with that key. A write stages and waits for confirmation first, so your API is not called until someone approves it.

If your API accepts only your users' own session tokens and has no key to issue, import without one and pass the signed-in person's token with each turn instead: see Calling your API as the signed-in user.

The same thing over HTTP is POST /v1/tools/import, which takes the document itself, not a url.

Everything arrives as a draft with nothing marked. A POST in an OpenAPI document is not read as a write — plenty of them are searches, and guessing wrong in that direction executes something.

No OpenAPI document? tether scan reads a checkout of your API's code on your machine and sends nothing. It finds a document you already have, or tells you where your framework serves one, or drafts one from your routes. See scanning your code.

To send calls to a tool endpoint you host instead of your API, use path C.


C. Register a catalog yourself

The CLI scaffolds a working tool endpoint and registers it, so the schema and the handler cannot drift apart. With the variables from signup set:

tether init --endpoint https://tools.yourcompany.com/tether
node server.js
tether register

init writes tools.js with one read and one write, and a server.js that serves them. register tells Tether where your endpoint lives, lints the descriptions, and registers the catalog. The address passes the same address checks as any tool endpoint, and only the tenant secret key can change it; see Tool endpoint. Replace the example tools in tools.js with your own.

To mount the handler in a server you already run, or to call the API without the CLI:

const { buildCatalog, createHandler } = require('tether')

const tools = {
  list_orders: {
    description: 'Lists recent orders for a shopper, newest first. Use when asked what was ordered.',
    schema: { type: 'object', properties: { limit: { type: 'integer', description: 'Max rows.' } } },
    handler: async ({ limit }, ctx) => db.orders.recent(ctx.tenant.scopeId, limit),
  },
  refund_order: {
    description: 'Refunds one order to the original card. Use only when the shopper names an order.',
    write: true,
    risk: 'high',
    schema: { type: 'object', properties: { order_id: { type: 'string', description: 'Order id.' } } },
    handler: async ({ order_id }) => billing.refund(order_id),
  },
}

app.post('/tether', express.raw({ type: '*/*' }), createHandler({ secret: process.env.TETHER_SECRET, tools }))

Tell Tether where the endpoint lives before you register anything, or every call answers No tool endpoint is configured for this tenant:

curl -X PUT $TETHER_URL/v1/endpoint \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://tools.yourcompany.com/tether" }'

buildCatalog(tools) produces the array PUT /v1/tools accepts.

Available for Node, Python and .NET, or write the catalog by hand — it is JSON.


Your first governed turn

However you registered, the turn is the same:

curl -X POST $TETHER_URL/v1/chat \
  -H "X-Api-Key: $TETHER_AGENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "prompt": "refund order Z-48211" }'

The write comes back staged, not done:

{
  "blocks": [ "…" ],
  "pending_actions": [
    { "action_id": "8f1c…", "tool": "refund_order", "summary": "Refund 899.00 to the card ending 4471" }
  ]
}

Someone approves it — through the API, the approval inbox, or your own UI — and only then does your endpoint get asked to perform it. Exactly once, even if two people click at the same moment.

Approve it

Take the action_id from pending_actions and confirm it with your secret key. Name the person approving in X-Tether-User-Id; it is a positive integer you choose. A high risk write also needs acknowledgeHighRisk, and an idempotencyKey makes a repeated confirm return the first result instead of an error:

curl -X POST $TETHER_URL/v1/actions/8f1c…/confirm \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "X-Tether-User-Id: 101" \
  -H "Content-Type: application/json" \
  -d '{ "acknowledgeHighRisk": true, "idempotencyKey": "first-confirm-1" }'

The answer is "status": "Executed", and your endpoint has now been asked to perform the write, once. Send the same request again and the status is Replayed; the write does not run twice.

For trusted integration diagnostics, run tether try, which reads your TETHER_SECRET_KEY. That command requests the route, tool and cost report. The report is deliberately unavailable to an agent credential, and the secret key must not be copied into the agent to obtain it.