Tether
DocsGitHub

API reference

Repository

A repository's own CI runner can keep the map Tether has of your API up to date. It runs tether analyze and tether publish --send where your code already is, with a CI key, and sends only three kinds of artifact: the OpenAPI contract as an import draft, the docs you choose as knowledge, and vocabulary as a suggestion. Your source code never leaves the runner. Nothing here reads a repository: Tether only receives what the runner sends.

The GitHub Action does this on every push to your default branch. These are the routes it uses, and the ones the dashboard uses to show you what arrived.

The CI key

A CI key starts tci_. Your secret key issues it, and it is shown once and stored as its SHA-256, like every key:

curl -X POST $TETHER_URL/v1/account/keys \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "ci", "label": "Booking API runner" }'
{ "key": "tci_…", "prefix": "tci_3b78ee2c", "kind": "ci" }

Add "allowReplace": true to issue one that may also replace a knowledge document you already hold under the same name (its kind is then ci_replace). Without it, a document already held is left alone and reported as exists. Revoke it with DELETE /v1/account/keys/{prefix}; a workflow still using it stops publishing on its next run. A provider issues one for its template with its provider key, POST /v1/provider/keys {"kind": "ci"}; that key lives on the provider's own tenant (see Providers).

Keep it as a repository secret (the Action reads secrets.TETHER_CI_KEY), never in a file or a link. The publish's secret scan finds a tci_ key in anything it would send.

What it reaches

RouteWith a CI key
GET /v1/repository/credentialWhat this key may send
POST /v1/tools/importThe draft step only: a document and its source, without operations, credential or skipUndeclared. Tether answers with its draft refusal and records the draft. Nothing is imported or connected
GET /v1/domain-packRead, so the publish suggests only domains the pack lacks
POST /v1/domain-pack/suggestionsFile vocabulary for a person to accept
GET /v1/knowledgeRead the names held
POST /v1/knowledgeStore a document carrying a repository source; replace: true only with a ci_replace key

Everything else is refused with 403 insufficient_scope and an error.reason, and leaves everything as it was:

error.reasonWhen
not_a_ci_routeAny other route: PUT /v1/domain-pack, accepting or dismissing a suggestion, PUT /v1/tools, PUT /v1/policy, PUT /v1/users, PUT /v1/endpoint, the webhook, any /v1/account route (keys included), deleting a document, chat, actions, the audit
ci_draft_onlyAn import that carries operations, a credential or skipUndeclared
ci_source_requiredAn import, a document or a suggestion without a repository source
ci_replace_not_allowedreplace: true from a key not issued with allowReplace
{
  "error": {
    "code": "insufficient_scope",
    "reason": "not_a_ci_route",
    "message": "A CI credential may only send what tether publish --send sends: …"
  }
}

So a CI key can never declare whether an operation writes, change the live pack, policy or catalog, or touch a key. Those stay a person's, with the secret key.

GET /v1/repository/credential

What the key presented may send. The CLI reads it before --replace, and refuses before sending anything when the key may not replace.

{ "kind": "ci", "prefix": "tci_3b78ee2c", "tenantId": 42, "tenantName": "Rehearsal Venue One", "ci": true, "allowReplace": false, "acceptsVocabulary": false }

With your secret key it answers kind: "secret", allowReplace: true, acceptsVocabulary: true.

What arrived

GET /v1/repository/publishes (secret key; limit, default 50) lists what reached Tether tagged with a repository source, from any key, newest first, and last: the latest publish, its commit, when, with which key, and one row per artifact. A row holds names and counts, never a document's text or the contract itself.

{
  "last": {
    "commit": "43cba9b6f6041f2d7c1e0a9b8c7d6e5f4a3b2c1d",
    "dirty": false,
    "receivedAt": "2026-10-16T17:40:12Z",
    "key": { "prefix": "tci_3b78ee2c", "kind": "ci" },
    "path": "docs/api/openapi.json",
    "arrivals": [
      {
        "kind": "contract",
        "name": "docs/api/openapi.json",
        "detail": {
          "outcome": "draft",
          "baseUrl": "https://api.example.com",
          "operations": [{ "name": "cancel_booking", "method": "POST", "path": "/bookings/{bookingId}/cancel", "suggested_is_write": true }]
        }
      },
      { "kind": "knowledge", "name": "cancellation-policy", "detail": { "version": 1, "chars": 412, "replaced": false } },
      { "kind": "vocabulary", "suggestionId": "d6d7ed99-…", "status": "pending", "detail": { "lexicon": { "bookings": ["booking", "party"] } } }
    ]
  },
  "arrivals": [],
  "count": 3
}

Each row also carries commit, receivedAt, key and its full source. A contract's outcome is draft (the import's first step) or imported (a person's declared import that carried a source).

Vocabulary suggestions

A suggestion is vocabulary waiting for a person. Filing one never changes the live pack.

POST /v1/domain-pack/suggestions

Secret key or CI key. Body: lexicon, an object of domain names (1 to 40, each 1 to 60 letters, digits, spaces, ., _ or -) to arrays of 1 to 200 words of at most 60 characters, and source, required ({kind: "repository", commit, path, dirty}, as on Knowledge). Words are stored trimmed, lower-cased, unique and sorted.

{ "filed": "filed", "suggestion": { "suggestionId": "d6d7ed99-…", "status": "pending", "lexicon": { "bookings": ["booking", "party"] }, "source": { "kind": "repository", "commit": "43cba9b6…", "path": ".tether/knowledge/domains.json", "dirty": false }, "receivedAt": "…", "key": { "prefix": "tci_3b78ee2c", "kind": "ci" }, "decidedAt": null, "packVersion": null } }

filed says what happened, so a runner that publishes on every push does not pile suggestions up:

filedMeaning
filedA new pending suggestion. An earlier pending one, if any, is now superseded
refreshedThe same vocabulary is already pending; it now carries this publish's source and time
already_dismissedA person dismissed this same vocabulary; it is not asked again

GET /v1/domain-pack/suggestions

Secret key. status is pending (the default), accepted, dismissed, superseded or all; limit defaults to 50. Answers { suggestions, count }.

POST /v1/domain-pack/suggestions/{suggestionId}/accept

Secret key: a person deciding. Writes a new pack version that adds every suggested domain the current pack does not already name (by key, ignoring case) and keeps every other field, its own domains, prompts, messages and language included. A domain already named is left exactly as it is.

{ "accepted": true, "packVersion": 4, "added": ["bookings"], "alreadyNamed": [], "suggestion": { "status": "accepted", "packVersion": 4 } }

If the pack already names every domain, nothing is written and packVersion is the current one.

POST /v1/domain-pack/suggestions/{suggestionId}/dismiss

Secret key. The suggestion becomes dismissed, and the same vocabulary is not filed again.

A decision is made once. Two people accepting at the same moment change the pack once: one gets 200, the other 409 suggestion_not_pending. An unknown id is 404 suggestion_not_found.