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 have | Path | |
|---|---|---|
| A | An MCP server your agent already talks to | Proxy it |
| B | Tool definitions or an OpenAPI document | Import them |
| C | Neither, or you want handlers in code | Register 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.