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
| Route | With a CI key |
|---|---|
GET /v1/repository/credential | What this key may send |
POST /v1/tools/import | The 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-pack | Read, so the publish suggests only domains the pack lacks |
POST /v1/domain-pack/suggestions | File vocabulary for a person to accept |
GET /v1/knowledge | Read the names held |
POST /v1/knowledge | Store 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.reason | When |
|---|---|
not_a_ci_route | Any 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_only | An import that carries operations, a credential or skipUndeclared |
ci_source_required | An import, a document or a suggestion without a repository source |
ci_replace_not_allowed | replace: 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:
filed | Meaning |
|---|---|
filed | A new pending suggestion. An earlier pending one, if any, is now superseded |
refreshed | The same vocabulary is already pending; it now carries this publish's source and time |
already_dismissed | A 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.
Related
- Keep your repository map up to date, the GitHub Action
- The tether CLI,
tether publish - Domain pack, Knowledge, Tools