Tether
DocsGitHub

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:

WarningWhy it matters
description_too_shortUnder 40 characters is rarely enough to choose between two tools
description_restates_nameThe description adds nothing the model did not already have
parameter_without_descriptionThe model has to guess what to put in the argument
indistinguishable_descriptionsTwo 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:

  1. An OpenAPI document already in the repository. If it finds a JSON one, it names it and stops. Import that.
  2. A framework that publishes its own document: FastAPI, Swashbuckle, Microsoft.AspNetCore.OpenApi, @nestjs/swagger or springdoc. It tells you where your running API serves it. That document knows your request bodies and types, so save it and import it.
  3. 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 requestBody to 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 second tether analyze reads only the files that changed. Delete the index and the next tether context rebuilds 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):

  1. An OpenAPI document you commit. Nothing is drafted.

  2. The document your framework wrote itself. Swashbuckle, NSwag, Microsoft.AspNetCore.OpenApi, @nestjs/swagger or 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-exec imports your application and calls app.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 beyond PATH and a throwaway HOME, 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.

  3. A parse of your code, with the optional analyzer. @tether/analyzer (sdk/analyzer in the Tether repository; npm ci there) 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.NET MapGroup chains, 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's requestBody carries 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 under unresolved in routes.json with its file and line, never guessed.

  4. 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, and tether.declarations.json is written with every is_write null. Whether an operation writes is yours to declare, exactly as in Importing an OpenAPI document: fill it in and run tether import with --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.md is cancellation-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.json is written on the runner, and is_write is never declared.
  • The vocabulary goes to POST /v1/domain-pack/suggestions as a suggestion a person accepts in the dashboard. It never reaches PUT /v1/domain-pack, and --accept-vocabulary is refused.
  • --replace is refused before anything is sent unless the key was issued with "allowReplace": true; GET /v1/repository/credential says 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 showsWhat happenedWhat to do
0 offered of 0 registeredNothing is registeredtether register
0 offered of 4 registeredThe router withheld everything it hadYour catalog's words and your users' words do not meet — see Turns and ceilings
2 offered of 4 registered, none calledThe model read the descriptions and declinedtether 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.