Guides
The tether CLI
Everything in the quickstart, as a sequence of commands — plus the one thing the HTTP API does not give you on its own: a report of why a turn did what it did.
The commands
tether init write tether.config.json, a starter tools.js and a starter server.js
tether lint check the catalog without sending it anywhere
tether doctor check the config, key, endpoint and catalog; name the fix for each
tether register validate the catalog, then register it
tether import <spec.json> turn an OpenAPI document into a catalog you can register
tether scan [dir] find an OpenAPI document, or draft one from HTTP routes, locally
tether analyze [dir] write .tether/knowledge/ and a local index of a checkout, locally
tether context query "<text>" rank the routes, files, docs and domains that answer it
tether context domain <name> a domain's routes, handlers, call chains, contract and docs
tether publish [dir] show what would reach Tether from .tether/knowledge/; --send sends it
tether api set change the imported API's credential or user-token mode, no re-import
tether try "<prompt>" run one turn and report the route, the tools and the cost
tether actions list writes staged and awaiting a decision
tether actions confirm <id> confirm one staged write
tether actions reject <id> reject one staged write
Every command takes --config <path>, --scope <id>, --user <id> and --json.
--json prints the raw response instead of a report, which is what you want in a script.
1. Configure
export TETHER_URL="https://api.trytether.ge"
tether init --endpoint https://tools.yourcompany.com/tether
init reads TETHER_URL for baseUrl and takes --endpoint, the public address your tool
endpoint will be reachable at. It also writes a working tools.js with one read and one write,
and a server.js that serves them, and never overwrites a file that already exists.
{
"baseUrl": "https://api.trytether.ge",
"endpoint": "https://tools.yourcompany.com/tether",
"tools": "./tools.js",
"scopeId": null,
"userId": null
}
tether register sets endpoint on your tenant before it registers the catalog, so there is
no separate call to make. Leave endpoint as null if your calls go to your own API instead.
TETHER_URL, when set, also overrides baseUrl on every later command, so moving to another
instance is export TETHER_URL=… and export TETHER_SECRET_KEY=… together, with no file to edit.
The API key is not in that file and never will be. A config file gets committed, and a key
that reaches a repository has to be reissued. It is read from TETHER_KEY on every call, or
from TETHER_SECRET_KEY, the name the quickstart uses, when TETHER_KEY is not set:
export TETHER_KEY="tth_…"
2. Point it at your tools
tools is a path to a module exporting your tool definitions — the same ones your handler
dispatches, so the registered schema cannot drift from the code that serves it.
module.exports = {
tools: {
list_orders: {
description: 'Lists recent orders, newest first. Use when the user asks what is outstanding.',
schema: { type: 'object', properties: { limit: { type: 'integer', description: 'Max rows.' } } },
handler: async ({ limit }, ctx) => db.orders.recent(ctx.tenant.scopeId, limit),
},
cancel_order: {
description: 'Cancels one order and releases its stock. Use only when the user names an order.',
write: true,
risk: 'high',
schema: { type: 'object', properties: { id: { type: 'string', description: 'Order id.' } } },
handler: async ({ id }) => db.orders.cancel(id),
},
},
}
A module exporting a { tools } object, a bare tools map, or an already-built catalog array
all work.
3. Lint before you register
tether lint
2 tool(s) valid.
1 warning(s):
cancel_order [parameter_without_description]
parameters have no description: id. The model guesses what to put in them.
These do not stop registration. They are the reason a model picks the wrong tool.
Two different checks run here. The first is the engine's own catalog validation, ported
verbatim — a catalog that passes locally passes
POST /v1/tools/validate, and a failure prints the message the API would
have returned. Those are errors; they stop the command.
The second is a set of warnings validation cannot raise, because none of them make a catalog invalid:
| Warning | Why it matters |
|---|---|
description_too_short | Under 40 characters is rarely enough to choose between two tools |
description_restates_name | The description adds nothing the model did not already have |
parameter_without_description | The model has to guess what to put in the argument |
indistinguishable_descriptions | Two tools read identically, so the model picks at random |
4. Register
tether register
2 tool(s) valid.
No warnings.
Registered version 3: 2 tool(s), 1 write(s).
Registration is a full replacement and it is versioned. See Tools.
Importing an OpenAPI document
For the case where you have an API and nobody free to write a tool endpoint against it. Run it once with no declarations:
tether import openapi.json --api https://api.example.com
It is refused, on purpose, and writes tether.declarations.json:
{
"cancel_order": {
"is_write": null,
"risk": null,
"_operation": "DELETE /orders/{orderId}",
"_suggested_is_write": true
}
}
Every is_write is null. The verb's opinion sits beside it as _suggested_is_write, visibly
a suggestion, so nobody accepts it by pressing enter.
Replace each null with true or false, give every write a risk, and run it again:
tether import openapi.json --api https://api.example.com --declare tether.declarations.json
That writes tether.catalog.json and, when tools in your config is still the starter tools.js
from init (or missing), points it at the catalog. A tools module you wrote yourself is left
alone, and the command says so. Nothing is registered yet: read the catalog, then
tether register.
Tether calls those operations at the base URL you gave, with a key you issue, read from
TETHER_API_CREDENTIAL — never a flag, because a flag lands in shell history. The base URL
passes the same address checks a tool endpoint does.
When your API accepts only your users' own session tokens, the page passes the signed-in person's
token with each turn in X-Tether-Upstream-Token (the widget's upstream-token), and
--user-token says how Tether uses it: ignore (the default), prefer (the person's token when
there is one, else the stored credential) or require (only the person's token; without one your
API is not called and the person is asked to sign in). A later import without --user-token keeps
the mode you chose. Change it, or the stored credential, without importing again:
tether import openapi.json --api https://api.example.com --declare tether.declarations.json --user-token require
tether api set --user-token prefer
TETHER_API_CREDENTIAL=sk_live_new tether api set
tether api set --clear-credential
tether api set calls PATCH /v1/tools/api with the
secret key and prints the mode and whether a credential is stored; the credential itself is never
printed or returned. Setting TETHER_API_CREDENTIAL and passing --clear-credential together is
refused. See Calling your API as the signed-in user.
No OpenAPI document? Scan your code
Run tether scan in a checkout of your API's code:
tether scan ./my-api
It reads files on your machine and sends nothing anywhere. It needs no config and no key. It looks for three things, best first:
- An OpenAPI document already in the repository. If it finds a JSON one, it names it and stops. Import that.
- A framework that publishes its own document: FastAPI, Swashbuckle,
Microsoft.AspNetCore.OpenApi,@nestjs/swaggeror springdoc. It tells you where your running API serves it. That document knows your request bodies and types, so save it and import it. - The HTTP routes in your code, from Express, Fastify, NestJS, FastAPI, Flask, ASP.NET and
Spring. It writes them to
tether.scan.openapi.json(or--out <path>), one operation per route, each marked with the file and line it came from.
The draft is where you start, not what you register. Every summary in it is empty, and the import refuses an operation without one. The summary is what the model chooses by, so write it in the words your customers use. Then:
- delete every route an assistant should not call,
- add a
requestBodyto each operation that takes one, because a scan cannot see request bodies, - import it as above:
tether import tether.scan.openapi.json --api https://api.example.com.
The draft does not say which operations write. You declare that at import, as with any other document.
A scan does not resolve a prefix applied in another file, such as app.use('/api', router) or a
base path set in configuration. Check each path against your running API, or use
tether analyze, which resolves mounts across files and, with the optional analyzer, routes
registered in loops and request bodies.
The same scan in the onboarding console
No terminal at hand? In the onboarding console, the import step's Pick your
project folder runs this scan and tether analyze in your browser, from the same code, on the
folder you pick. It skips what the CLI skips before opening anything, uses the tree-sitter parser
when it loads in time and matches its pinned SHA-256 (and says so, or says why the heuristics ran
instead), and never runs your code. It shows what it found (your committed document, one your
framework wrote, or the routes it drafted with their request bodies and anything it could not
resolve, each with its file and line) and the exact body it would send. No file leaves your
browser: only that draft goes to the import's first step, as tether publish sends it, and only
after the same secret scan has passed. Then you declare each operation as with an uploaded file.
Project knowledge for local agents
tether analyze reads the same checkout more deeply, still on your machine and still sending
nothing anywhere. It needs Node 22.5 or later, for node:sqlite.
tether analyze ./my-api
cd my-api
tether context query "where are password resets handled"
tether context domain resets --json
It writes two things into the checkout:
.tether/knowledge/, which you commit. A manifest (the commit it was made at, whether the checkout differed from it, and every file read with its hash), the API contract (your committed OpenAPI document, or a draft), every route with its full path and the handler file and function behind it, the domains it found with their vocabulary, the import and call edges with each route's chain from handler to service to repository, and the docs and policies it found, by title and heading. The files are sorted JSON with no timestamps, so a change reads as a diff. Source code and doc text are not copied into them..tether/index.sqlite, which git ignores. A full-text index over those files. A secondtether analyzereads only the files that changed. Delete the index and the nexttether contextrebuilds it from.tether/knowledge/without reading your source.
tether context query ranks routes, functions, files, contract operations, docs and domains,
and says why each matched. tether context domain gives one domain's routes, handler files,
call chains, components by layer, contract excerpt and docs. Both take --json, which is what an
agent wants. Georgian headings and vocabulary are indexed and matched by their stems.
Nothing here reaches Tether: neither command needs a key or a host.
How exact the contract is
tether analyze takes the contract from the most exact source it finds, in this order, and the
manifest's contracts say which (committed, generated or drafted):
-
An OpenAPI document you commit. Nothing is drafted.
-
The document your framework wrote itself. Swashbuckle, NSwag,
Microsoft.AspNetCore.OpenApi,@nestjs/swaggeror springdoc output already in your build output (bin/,obj/,dist/,target/), so run your build first; it is only as current as that build. For FastAPI, pass--allow-exec:tether analyze ./my-api --allow-exec--allow-execimports your application and callsapp.openapi(), which means your code runs on your machine. That is your decision to make, so it is off unless you pass the flag; without it, the analysis says what the flag would do and drafts the contract instead. With it, Python gets no environment variables beyondPATHand a throwawayHOME, writes no bytecode into your checkout, has network and subprocess calls refused, and is stopped after 30 seconds. That keeps an import from reaching a database or the network by accident. It is not a sandbox against code written to get out of one. -
A parse of your code, with the optional analyzer.
@tether/analyzer(sdk/analyzerin the Tether repository;npm cithere) reads JavaScript, TypeScript, Python, C# and Java with tree-sitter. It resolves prefixes set in other files (including a prefix held in a constant, a mount written over several lines, ASP.NETMapGroupchains, a NestJS global prefix, a Spring base path built from constants), routes registered in a loop over a constant list, and request bodies from Pydantic models, TypeScript types, zod and joi schemas, NestJS DTOs, and C# and Java records and classes. The draft'srequestBodycarries their fields, types and required lists, and the import turns them into the tool's arguments. A route whose path is not a constant in your code is listed underunresolvedinroutes.jsonwith its file and line, never guessed. -
The built-in heuristics, when the analyzer is not installed: prefixes across files for single-line mounts, no loops and no request bodies.
The output and manifest.json's analyzer say which ran: tree-sitter@<version> or heuristic.
Set TETHER_ANALYZER=heuristic to use the heuristics anyway, or TETHER_ANALYZER=<path> to name
the analyzer to load. Either way the summaries stay empty: what an operation does, in your
customers' words, is yours to write.
Publishing from your repository
tether publish is the one command that sends anything from an analysed checkout, and it sends
three kinds of thing only. It reads .tether/knowledge/ and the Markdown docs it lists. It never
opens a source file, and it never sends your routes, the dependency graph, the file list or the
index.
export TETHER_URL="https://api.trytether.ge"
export TETHER_SECRET_KEY="tth_…"
tether publish ./my-api --api https://api.example.com
That is a dry run, the default. It prints every request it would make: the artifact, the route,
its size, the files it comes from and the commit. --json prints the same with each request's
exact body.
tether publish: a dry run. Nothing was sent; add --send to send what is marked ready.
Checkout /home/you/my-api
commit 4e1c9a… (main), clean; every artifact is tagged source {kind: repository, commit, path, dirty: false}
1. contract POST /v1/tools/import contracts/docs/api/openapi.json 3.2 KiB [ready]
from .tether/knowledge/contracts/docs/api/openapi.json, docs/api/openapi.json
sent without operations or a credential: Tether answers with the draft, and is_write stays a person's to declare
2. vocabulary PUT /v1/domain-pack 1.9 KiB [ready]
merged over the tenant's pack version 3:
+ cancel: booking, bookings, cancel, cancellation, policy, გაუქმების, წესი, ჯავშნის
= bookings: the tenant's own, unchanged (suggested bookings, 10 word(s))
sent only with --accept-vocabulary
3. knowledge POST /v1/knowledge cancellation-policy 944 B [ready]
from docs/cancellation-policy.md
Secret scan: nothing found in 17.5 KiB (every file read and every request body).
Never sent: source code, routes.json, dependencies.json, the manifest's file list, index.sqlite.
Add --send to send it. What each kind does:
- The contract goes to the import's first step only, with no
declarations and no credential, so Tether answers with its draft and its
suggested_is_write, andtether.declarations.jsonis written with everyis_writenull. Whether an operation writes is yours to declare, exactly as in Importing an OpenAPI document: fill it in and runtether importwith--declare. - The vocabulary is a suggestion for your domain pack. The preview
shows it merged over your current pack: a domain you already name is left exactly as you wrote
it, and your tone, messages and language are not touched. It is sent only with
--accept-vocabulary. - The docs become knowledge documents named after the file
(
docs/cancellation-policy.mdiscancellation-policy): all of them, or those matching--docs <glob>. A name you already hold is replaced only with--replace.
Each is tagged with source: the commit, the file and whether the checkout was dirty. Tether keeps
it with the knowledge document and with the import, so you can always see which commit a policy or
a contract came from. --send refuses a checkout that differs from its commit unless you pass
--allow-dirty, and one that has moved since the analysis until you run tether analyze again.
Before anything is sent, every byte that would be is scanned for secrets: private keys, AWS,
GitHub, Slack, Stripe and Google keys and tokens, JWT-like tokens, Tether keys, and high-entropy
values assigned to a name like secret, token, password or key. A finding stops the whole
publish and names the file and line, with the secret redacted and a fingerprint. If you have
checked it and it is not a secret, pass --allow-secret <fingerprint>.
The key comes from TETHER_KEY or TETHER_SECRET_KEY, never a flag, and must be the tenant's
secret key or its CI key. A publishable, inbox, agent or provider key is refused. A software
provider publishes to its template with its own provider tenant's secret key, or a CI key it
issued with POST /v1/provider/keys; its businesses copy the pack and the knowledge when they are
created.
From a CI runner
With a CI key (tci_), the same command is narrower, and
Tether holds it to that on its side too. It is what the
GitHub Action runs on your own runner:
- The contract stops at the draft refusal, which Tether records. No
tether.declarations.jsonis written on the runner, andis_writeis never declared. - The vocabulary goes to
POST /v1/domain-pack/suggestionsas a suggestion a person accepts in the dashboard. It never reachesPUT /v1/domain-pack, and--accept-vocabularyis refused. --replaceis refused before anything is sent unless the key was issued with"allowReplace": true;GET /v1/repository/credentialsays which.
TETHER_KEY="tci_…" tether publish . --api https://api.example.com --send
Two more options narrow what is sent: --only <kinds> takes any of contract, vocabulary and
knowledge, comma-separated, and --contract <file> picks the contract when the analysis holds
more than one.
Tether's own rehearsal runs this sequence against both of its stand-in providers, and the provider guide shows it as an optional step. A repository is never required: everything here only prefills what you would otherwise write by hand.
(cd ./my-api && tether analyze . --json)
tether publish ./my-api --api https://api.example.com --json
TETHER_KEY="tth_…" tether publish ./my-api --api https://api.example.com --send --accept-vocabulary --json
When something does not work
tether doctor
OK config https://api.trytether.ge
OK key TETHER_KEY or TETHER_SECRET_KEY is set
OK server https://api.trytether.ge/health answered 200
OK key accepted the instance recognises the key
OK catalog version 3: 2 tool(s), 1 write(s)
OK tool endpoint https://tools.yourcompany.com/tether answers and refuses an unsigned request
It checks in the order a first setup breaks, and under every line that is not OK prints a fix:.
The last check posts an unsigned request to your tool endpoint from your machine and expects a
401: a server that is down or on the wrong path fails, and one that would run an unsigned request
fails too, because that is a tool endpoint anyone could call. Nothing is changed and no tool runs.
The exit code is non-zero if any line is FAIL. A WARN on the tool endpoint means none is set,
which is fine when every tool is imported or proxied.
5. Run a turn, and see why it did that
tether try "what is still open?"
Two orders are still open.
Open orders: 2
Why this turn did what it did
route analysis
result ok
model …
tools 2 offered of 2 registered
tools called list_orders
tokens 412 in / 88 out
cost $0.002140
time 1840ms total, 1610ms in the model
matched alias orders
signals alias_deterministic, live_data
Called list_orders.
session 0f2c…
The report is the point. Why did my turn call no tools is the first question every integration raises, and it has three different answers that look identical from the outside:
| What the report shows | What happened | What to do |
|---|---|---|
0 offered of 0 registered | Nothing is registered | tether register |
0 offered of 4 registered | The router withheld everything it had | Your catalog's words and your users' words do not meet — see Turns and ceilings |
2 offered of 4 registered, none called | The model read the descriptions and declined | tether lint |
Pair it with mock mode and the whole loop runs with no provider key and nothing spent.
6. Confirm what it staged
A turn that asked for a write comes back with the action staged, not performed:
Staged, awaiting confirmation:
8f1c… cancel_order Cancel order #4182
Confirm with: tether actions confirm <id>
tether actions
tether actions confirm 8f1c… --ack
--ack is the risk acknowledgement an elevated or high write requires. Without it, the
confirm is refused rather than quietly downgraded. See
Your first write.
Related
- Node SDK — where the CLI lives
- Quickstart — the same path over plain HTTP
- Testing with mock mode — run all of this with no provider key
- Choosing risk levels — which writes should stop and ask