Tether
DocsGitHub

Guides

Connect your platform

You build software that many businesses run, such as a booking platform for restaurants. You connect it to Tether once. Each business on your platform then gets an agent its own customers can ask, in Georgian, to do something: the agent checks who is asking and whether the request is allowed, waits for the business's staff where its policy says so, does it once through your API, reads the result back, and hands off to a person when it cannot help.

This guide goes from nothing to one business's first verified action, in the order Tether's own rehearsal does it. Every request below is a request the rehearsal sends, against a stand-in booking platform with venues, guests and bookings (docs/loops/mvp/booking/ in the Tether repository). A second stand-in, a shop platform with integer order numbers, money in minor units and a nested address (docs/loops/mvp/shop/), passes the same rehearsal with no change to Tether, and is the second worked example where its shape differs. The script docs/loops/mvp/follow-guide.sh runs the commands on this page, as written, against a local Tether, and fails if one of them stops working.

Who does what

StepWhoCredential
1. Create your tenantThe operator of the Tether deploymentOperator key
2. Describe your APIYouYour tenant's secret key, tth_
3. Set your templateYouYour tenant's secret key
4. Grant provider authorityThe operatorOperator key
5. Create a businessYouYour provider key, tpv_
6. Register the business's staffYou, for the businessThe business's secret key
7. Mint customer tokensYour platform's backend, for each businessThe business's customer-token key, tck_
8. Embed the agentYour platform's pagesThe business's publishable key, tpk_
9. Approve in the inboxThe business's staffYour inbox key, tib_, on the inbox host
10. Read the outcomesYou and the businessProvider key; the business's secret key

The commands use these variables. The operator gives you the first; the rest are yours.

export TETHER_URL=https://api.trytether.ge            # the Tether deployment
export TETHER_OPERATOR_KEY=...                          # steps 1 and 4, the operator's
export PLATFORM_API=https://api.your-platform.example  # your API, as Tether will call it
export PLATFORM_API_TOKEN=...                           # a credential your API accepts from Tether
export PLATFORM_EVENTS_URL=https://api.your-platform.example/tether/events
export APPROVALS_URL=https://approvals.your-platform.example  # where the inbox will run

The commands need curl and jq, and step 7 needs Python 3.

1. The operator creates your tenant

curl -sS -X POST "$TETHER_URL/v1/tenants" \
  -H "X-Api-Key: $TETHER_OPERATOR_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Rehearsal Booking Platform" }' -o provider-tenant.json
PROVIDER_TENANT_ID=$(jq -er .tenantId provider-tenant.json)
PROVIDER_SECRET_KEY=$(jq -er .apiKey provider-tenant.json)

Your tenant is where your configuration lives: the API description, the vocabulary and the policy every business you create starts from. It is an ordinary tenant, so you can try everything on it before any business exists. The secret key is shown once.

2. Describe your API

Tether calls your API directly, from an OpenAPI document, with a credential you issue. Save your document as openapi.json. The first import returns a draft and registers nothing:

jq -n --slurpfile doc openapi.json --arg base "$PLATFORM_API" \
  '{ document: $doc[0], baseUrl: $base }' > draft-request.json
curl -sS -X POST "$TETHER_URL/v1/tools/import" \
  -H "X-Api-Key: $PROVIDER_SECRET_KEY" -H "Content-Type: application/json" \
  -d @draft-request.json -o draft.json
jq -r '.error' draft.json
jq -c '.operations[] | { name, suggested_is_write }' draft.json
Import refused — 5 operation(s) do not declare is_write, and an HTTP verb does not decide whether an operation writes — declare each one: cancel_booking, change_booking, get_booking_status, get_venue, list_guest_bookings.
{"name":"get_venue","suggested_is_write":false}
{"name":"list_guest_bookings","suggested_is_write":false}
{"name":"get_booking_status","suggested_is_write":false}
{"name":"change_booking","suggested_is_write":true}
{"name":"cancel_booking","suggested_is_write":true}

It answers 400 with imported: false, because every operation must be declared a read or a write: Tether never guesses from the HTTP method. Write the declarations. These are the booking platform's:

{
  "get_venue": { "is_write": false, "domain": "venues" },
  "list_guest_bookings": { "is_write": false, "domain": "bookings", "requires_customer": true },
  "get_booking_status": { "is_write": false, "domain": "bookings", "requires_customer": true },
  "change_booking": {
    "is_write": true, "risk": "elevated", "domain": "bookings", "requires_customer": true,
    "precondition": { "tool": "get_booking_status", "arguments": { "bookingId": { "from": "bookingId" } }, "check": { "path": "changeable", "equals": true } },
    "verify": { "tool": "get_booking_status", "arguments": { "bookingId": { "from": "bookingId" } }, "check": { "path": "partySize", "from": "partySize" } }
  },
  "cancel_booking": {
    "is_write": true, "risk": "high", "domain": "bookings", "requires_customer": true,
    "precondition": { "tool": "get_booking_status", "arguments": { "bookingId": { "from": "bookingId" } }, "check": { "path": "changeable", "equals": true } },
    "verify": { "tool": "get_booking_status", "arguments": { "bookingId": { "from": "bookingId" } }, "check": { "path": "status", "equals": "cancelled" } }
  }
}
FieldWhat it declares
is_writeRequired on every operation. A write is staged, shown to a person and executed only when confirmed. See reads and writes
risklow, elevated or high on a write: how long it may wait, and what the policy can key on. See choosing risk levels
requires_customerThe operation acts for one of the business's customers, so it is neither called nor staged without a verified customer. Your API receives that customer as X-Tether-Customer-Id and enforces ownership with it
preconditionA read Tether makes before staging and again at the confirm, and a check on its answer. If the business's own system says no (the booking has started, the order has shipped), nothing is staged, or the confirm is refused. See the tool spec
verifyA read made once after the write succeeds, and a check that the change took. The action is recorded verified or unverified, and an unverified one is handed to a person
outcomesOptional: how a non-success status from your API is read on a confirmed write. See below

A check compares a path in the read's answer with a value or with one of the write's arguments. The shop platform checks a refund by amount, not by a flag, and verifies an address it changed by comparing the whole object, members in any order:

"refund_order": {
  "is_write": true, "risk": "elevated", "domain": "payments", "requires_customer": true,
  "precondition": { "tool": "order_details", "arguments": { "order_number": { "from": "order_number" } }, "check": { "path": "refundable_minor", "gte": { "from": "amount_minor" } } },
  "verify": { "tool": "order_details", "arguments": { "order_number": { "from": "order_number" } }, "check": { "path": "last_refund.amount_minor", "from": "amount_minor" } }
},
"change_delivery_address": {
  "is_write": true, "risk": "elevated", "domain": "delivery", "requires_customer": true,
  "precondition": { "tool": "order_details", "arguments": { "order_number": { "from": "order_number" } }, "check": { "path": "delivery.editable", "equals": true } },
  "verify": { "tool": "track_shipment", "arguments": { "order_number": { "from": "order_number" } }, "check": { "path": "ship_to", "from": "address" } }
}

When your API says no. If a confirmed write gets a 4xx from your API (other than 408 and 429), Tether reads it as your refusal: the action settles once as refused, with your error code and message as the reason, and is never retried. A 5xx, a timeout or a lost connection after sending is held as indeterminate, because the write may have happened. If one of your 4xx statuses means "still running" rather than "no", declare it, for example "outcomes": { "409": "indeterminate" }. Neither stand-in needs to. The whole rule is in When your API refuses a confirmed write.

Now import with the declarations and the credential, and register the catalog it returns:

jq -n --slurpfile doc openapi.json --slurpfile ops declarations.json \
  --arg base "$PLATFORM_API" --arg credential "$PLATFORM_API_TOKEN" \
  '{ document: $doc[0], baseUrl: $base, credential: $credential, operations: $ops[0] }' > import-request.json
curl -sS -X POST "$TETHER_URL/v1/tools/import" \
  -H "X-Api-Key: $PROVIDER_SECRET_KEY" -H "Content-Type: application/json" \
  -d @import-request.json -o imported.json
jq -e '.imported and .writeToolCount == 2' imported.json
jq .catalog imported.json > catalog.json
curl -sS -X PUT "$TETHER_URL/v1/tools" \
  -H "X-Api-Key: $PROVIDER_SECRET_KEY" -H "Content-Type: application/json" \
  -d @catalog.json | jq -e .registered

The credential is stored encrypted and sent to your API as a bearer token. Your API is called with it, never with a business's key.

3. Set your template

Three more things on your own tenant, and every business you create starts with a copy.

The domain pack: the words your customers use for what your platform holds, in Georgian, Russian and English, the staff mail language (ka or en), the tone, and the message a customer sees when they ask for a person.

{
  "lexicon": {
    "bookings": [
      "ჯავშანი", "ჯავშანს", "ჯავშნის", "ჯავშნები", "ჯავშანზე", "დაჯავშნა",
      "бронь", "брони", "броней", "бронирование", "бронирования",
      "booking", "bookings", "reservation", "reservations"
    ],
    "venues": [
      "რესტორანი", "რესტორნის", "მისამართი", "სამუშაო საათები",
      "ресторан", "ресторана", "адрес",
      "venue", "address", "opening hours"
    ]
  },
  "prompts": {
    "tone": "Answer as the venue's own assistant: short, concrete, and in the language the guest wrote in. Never say a booking changed unless a tool said so."
  },
  "outOfScopeMessage": "I can help with your bookings at this venue. For anything else, please contact the venue.",
  "handoffMessage": "ვენიუს თანამშრომელი გიპასუხებთ აქვე ან დაგირეკავთ. A member of the venue's staff will reply here or call you.",
  "language": "ka"
}

The policy: which staff role may approve which write. A rule may carry a condition on the write's arguments; the most specific rule that matches decides. Here a host may approve a change for a party of six or fewer, and anything larger, or any cancellation, needs the manager:

{
  "rules": [
    { "tool": "cancel_booking", "risk": "high", "roles": ["manager"], "approvers": 1 },
    { "tool": "change_booking", "risk": "elevated", "roles": ["host", "manager"], "approvers": 1, "when": { "partySize": { "lte": 6 } } },
    { "tool": "change_booking", "risk": "elevated", "roles": ["manager"], "approvers": 1 },
    { "tool": "*", "risk": "high", "roles": ["manager"], "approvers": 1 },
    { "tool": "*", "risk": "elevated", "roles": ["host", "manager"], "approvers": 1 }
  ]
}

The events webhook: where Tether tells your platform what happened, signed with each business's own signing secret.

curl -sS -X PUT "$TETHER_URL/v1/domain-pack" \
  -H "X-Api-Key: $PROVIDER_SECRET_KEY" -H "Content-Type: application/json" \
  -d @pack.json | jq -e .registered
curl -sS -X PUT "$TETHER_URL/v1/policy" \
  -H "X-Api-Key: $PROVIDER_SECRET_KEY" -H "Content-Type: application/json" \
  -d @policy.json | jq -e .registered
curl -sS -X PUT "$TETHER_URL/v1/events/webhook" \
  -H "X-Api-Key: $PROVIDER_SECRET_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg url "$PLATFORM_EVENTS_URL" '{ url: $url }')" | jq -e '.effectiveSource == "events_webhook"'

Documents you upload to your own tenant with POST /v1/knowledge, such as a cancellation policy every venue shares, are part of the template too. The rehearsal uploads its document to the business instead, in step 10.

Optional: if you have a repository

No step on this page needs one, and nothing after this section depends on it. If your API's code is in a git checkout on your machine, the tether CLI can fill in part of steps 2 and 3 from it: the contract, vocabulary for your pack, and your policies as knowledge. The analysis runs on your machine and sends nothing. Only tether publish --send sends anything, and it sends those three kinds of thing only, each tagged with the commit it came from. Source code, routes, the file list and the local index never leave the machine. It needs Node 22.5 or later.

These are the commands Tether's rehearsal runs for both stand-ins (its step repository_analysis_prefills_drafts), with your checkout in place of the stand-in's:

REPOSITORY=path/to/your-api-checkout
(cd "$REPOSITORY" && tether analyze . --json)
tether publish "$REPOSITORY" --api "$PLATFORM_API" --json

The second command is a dry run: it needs no key and sends nothing (given a key, it only reads your pack and documents to show the merge), and it prints every request it would make with its exact body (drop --json for a readable report). It also scans every byte it would send for secrets, and a finding stops the publish, naming the file and line. Read it, then send:

TETHER_KEY=$PROVIDER_SECRET_KEY tether publish "$REPOSITORY" --api "$PLATFORM_API" --send --accept-vocabulary --json > published.json
jq -c '.results[] | { kind, name, outcome }' published.json

Use your own tenant's secret key, as in steps 2 and 3, so your businesses copy what you publish. What each artifact does:

  • The contract goes to the import's first step only, with no declarations and no credential. Tether answers with the same draft and suggested_is_write as in step 2, and the CLI writes tether.declarations.json in the current directory, never over an existing one, with every is_write null. Nothing is registered. Whether an operation writes, and its risk, precondition and verify, are still yours to declare: fill it in (it becomes declarations.json above) and import it with the credential as in step 2. To keep the commit with the import, send the source the draft echoed:
jq -n --slurpfile doc openapi.json --slurpfile ops declarations.json \
  --arg base "$PLATFORM_API" --arg credential "$PLATFORM_API_TOKEN" \
  --argjson source "$(jq -c '.results[] | select(.kind == "contract") | .source' published.json)" \
  '{ document: $doc[0], baseUrl: $base, credential: $credential, operations: $ops[0], source: $source }' > import-request.json

Then send import-request.json and register the catalog exactly as in step 2. If your repository commits no OpenAPI document, the contract is a draft from your routes, under .tether/knowledge/contracts/, with every summary empty; use it as openapi.json once you have described each operation, because the import refuses an operation with no description.

  • The vocabulary is merged over your 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. A later PUT /v1/domain-pack replaces the whole pack, so run this after step 3, as here.
  • Your docs and policies become knowledge documents named after the file, part of your template like any other.

GET /v1/tools shows the import's source and GET /v1/knowledge each document's, so you can see which commit a contract or a policy came from. The whole command is in Publishing from your repository.

Optional: keep it up to date

Once the template is set, your repository's own CI runner can repeat this on every push to your default branch, with a GitHub Action that runs tether analyze and tether publish --send where your code already is. Your source code never leaves the runner. It publishes with a CI key (tci_), which can file the contract's draft, your docs and a vocabulary suggestion and nothing else: it cannot declare whether an operation writes, change your live pack, policy or catalog, or touch a key. Issue one for your own tenant, the template, with your secret key (or your provider key, POST /v1/provider/keys, once you hold one):

curl -s -X POST "$TETHER_URL/v1/account/keys" -H "X-Api-Key: $PROVIDER_SECRET_KEY" \
  -H "Content-Type: application/json" -d '{"kind": "ci", "label": "template repository"}' | jq -r .key

Add it to the repository as the secret TETHER_CI_KEY, and add the workflow .github/workflows/tether.yml from Keep your repository map up to date; the dashboard's Repository section fills it in and opens GitHub's new-file page for you. Each run prints its plan in the job summary and stops, sending nothing, on a secret-scan finding. Vocabulary arrives as a suggestion you accept in the dashboard, so your pack changes only when you say so. Tether's rehearsal checks this for both stand-ins (its step ci_publish_cannot_change_live_config).

4. The operator grants provider authority

curl -sS -X POST "$TETHER_URL/v1/tenants/$PROVIDER_TENANT_ID/provider" \
  -H "X-Api-Key: $TETHER_OPERATOR_KEY" -o grant.json
PROVIDER_KEY=$(jq -er .providerKey grant.json)

The provider key, tpv_, is shown once. It reaches /v1/provider and nothing else: it cannot chat, change a catalog or read any business's conversations, actions or audit. Your secret key, in turn, cannot create a business.

5. Create a business

curl -sS -X POST "$TETHER_URL/v1/provider/tenants" \
  -H "X-Api-Key: $PROVIDER_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Rehearsal Venue One" }' -o business.json
BUSINESS_ID=$(jq -er .tenantId business.json)
BUSINESS_SECRET_KEY=$(jq -er .apiKey business.json)
BUSINESS_SIGNING_SECRET=$(jq -er .signingSecret business.json)
CUSTOMER_TOKEN_KEY=$(jq -er .customerTokenKey business.json)
jq -c '[.configuration[].part]' business.json
["catalog","domain_pack","policy","upstream:api","events_webhook"]

The business is its own tenant, with your catalog, pack, policy, API connection and events webhook copied into it (configuration lists the parts). Three secrets come back once, and your platform's backend keeps them for this business:

FieldUsed for
apiKeyThe business's secret key: its staff, its knowledge, its outcome report, server-side turns
signingSecretVerifying the events Tether sends your webhook for this business
customerTokenKeySigning the customer tokens of step 7. Never sent to a browser

A business does not follow later changes to your template on its own; apply them with POST /v1/provider/tenants/{id}/configuration. A provider reaches only the businesses it created: any other id is 404 not_a_child_tenant.

6. Register the business's staff

Approvers are the business's own people, never copied from your template. Each has an id in your platform, a role the policy names, and the address Tether mails:

[
  { "userId": 501, "role": "manager", "email": "manager@example.com", "displayName": "Venue manager" },
  { "userId": 502, "role": "host", "email": "host@example.com", "displayName": "Venue host" }
]
curl -sS -X PUT "$TETHER_URL/v1/users" \
  -H "X-Api-Key: $BUSINESS_SECRET_KEY" -H "Content-Type: application/json" \
  -d @users.json | jq -e .registered

7. Mint customer tokens

The agent acts for a guest only when your platform says, in a token it signed, which guest is signed in. The guest is never read from the conversation. A token is a compact JWT, HS256, with sub (your id for the guest), tenant (the business), iat and exp, at most an hour apart. This is the rehearsal's own minting code:

import base64, hashlib, hmac, json, sys, time


def b64(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def customer_token(key, tenant_id, customer_id, ttl=300):
    iat = int(time.time())
    header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
    payload = b64(json.dumps({"sub": customer_id, "tenant": tenant_id, "iat": iat, "exp": iat + ttl},
                             separators=(",", ":")).encode())
    signature = hmac.new(key.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()
    return f"{header}.{payload}.{b64(signature)}"


print(customer_token(sys.argv[1], int(sys.argv[2]), sys.argv[3]))
TOKEN=$(python3 mint_token.py "$CUSTOMER_TOKEN_KEY" "$BUSINESS_ID" G1)

The Node, Python and .NET SDKs mint the same token (mintCustomerToken, mint_customer_token, TetherCustomerToken.Mint). A forged, expired or other business's token is refused 401 invalid_customer_token with a reason, never served as an anonymous visitor. Details are in verified customers.

8. Embed the agent

Issue the business a publishable key and register the origin of the page that embeds it:

curl -sS -X POST "$TETHER_URL/v1/provider/tenants/$BUSINESS_ID/keys" \
  -H "X-Api-Key: $PROVIDER_KEY" -H "Content-Type: application/json" \
  -d '{ "kind": "publishable", "label": "widget" }' -o publishable.json
PUBLISHABLE_KEY=$(jq -er .key publishable.json)
curl -sS -X PUT "$TETHER_URL/v1/origins" \
  -H "X-Api-Key: $BUSINESS_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{ "origins": ["https://venue-one.your-platform.example"] }' | jq -c .

Then the page, with the token your backend minted for the signed-in guest. actions lets the guest ask for an operation; without it the agent only reads:

<script src="/tether-assistant.js"></script>
<tether-assistant
  endpoint="https://api.trytether.ge"
  publishable-key="tpk_…"
  customer-token="eyJhbGciOi…"
  actions></tether-assistant>

The widget guide covers the rest. A publishable key can chat and stage, and never approve.

9. The first request, in Georgian

A guest asks to cancel their booking. This is the turn the widget sends, made from a backend with the business's secret key, as the rehearsal makes it:

curl -sS -X POST "$TETHER_URL/v1/chat" \
  -H "X-Api-Key: $BUSINESS_SECRET_KEY" -H "Content-Type: application/json" \
  -H "X-Tether-Customer-Token: $TOKEN" -H "X-Tether-Explain: 1" \
  -d '{ "prompt": "გააუქმე ჩემი ჯავშანი B102", "enableWriteActions": true }' -o turn.json
ACTION_ID=$(jq -er '.pending_actions[0].action_id' turn.json)
jq -c '{ tool: .pending_actions[0].tool, customer_language: .meta.customer_language, route: .meta.explain.route }' turn.json
{"tool":"cancel_booking","customer_language":"ka","route":"tool_calling"}

Nothing has happened to the booking yet. Tether read it first, as guest G1 (the precondition), and staged the cancellation for approval. Your webhook receives a signed action.pending, and Tether mails the business's manager, the only role the policy lets approve a cancellation, in the pack's language. Had the booking already started, nothing would have been staged and the guest would be told why.

10. Staff approve in the inbox

The approval inbox is a small app you run once for all your businesses. Issue it an inbox key, tib_, which can sign a business's staff in and, in their session, approve, and nothing else:

curl -sS -X POST "$TETHER_URL/v1/provider/keys" \
  -H "X-Api-Key: $PROVIDER_KEY" -H "Content-Type: application/json" \
  -d '{ "kind": "inbox", "label": "inbox" }' -o inbox-key.json
TETHER_INBOX_KEY=$(jq -er .key inbox-key.json)

Run it from the Tether repository's apps/inbox, at the address the operator set as Mail:Inbox:BaseUrl:

cd apps/inbox && bun install && bun run build
TETHER_URL=$TETHER_URL TETHER_INBOX_KEY=$TETHER_INBOX_KEY INBOX_SECRET=$(openssl rand -hex 32) \
  bunx next start -p 3200

The manager opens the inbox and enters their address. Tether mails one sign-in link for each business where that address is registered; an unregistered address gets the same answer and no message. These are the requests the inbox's pages send, shown with curl:

curl -sS -X POST "$APPROVALS_URL/api/session" \
  -H "Content-Type: application/json" -d '{ "email": "manager@example.com" }' | jq -c .
{"sent":true,"message":"If that address is registered, a sign-in link is on its way. Asking again is limited, so if a few minutes pass, check spam and use the newest link you have."}

The link in the message (SIGN_IN_URL here) opens once, within ten minutes, and sets the inbox's session cookie:

curl -sS -o /dev/null -D verify.headers "$SIGN_IN_URL"
INBOX_SESSION=$(grep -i '^set-cookie: tether_inbox_session=' verify.headers | sed 's/^[^=]*=//; s/;.*//' | tr -d '\r')
curl -sS "$APPROVALS_URL/api/actions" -H "Cookie: tether_inbox_session=$INBOX_SESSION" \
  | jq -c --arg id "$ACTION_ID" '.actions[] | select(.actionId == $id) | { tool, subject, allowed: .permission.allowed }'
curl -sS -X POST "$APPROVALS_URL/api/actions/$ACTION_ID/approve" \
  -H "Cookie: tether_inbox_session=$INBOX_SESSION" -H "Content-Type: application/json" \
  -d '{ "tool": "cancel_booking", "arguments": { "bookingId": "B102" }, "acknowledgeHighRisk": true }' -o approved.json
jq -c '{ status, verification: .verification.status, reason: .verification.reason }' approved.json
{"tool":"cancel_booking","subject":"G1","allowed":true}
{"status":"Executed","verification":"verified","reason":"get_booking_status reports status = \"cancelled\"."}

The approval does four things in order. Tether checks the manager may approve it, reads the booking again (it could have changed since staging), sends the cancellation to your API once, and reads the booking back. The answer is Executed with verification.status verified. Two people approving at once still produce one write: the others are told it was already done.

If your platform has its own staff screens, it can confirm through the API instead, with the business's secret key and your id for the approver:

curl -sS -X POST "$TETHER_URL/v1/actions/$ACTION_ID/confirm" \
  -H "X-Api-Key: $BUSINESS_SECRET_KEY" -H "X-Tether-User-Id: 501" \
  -H "Content-Type: application/json" -d '{ "acknowledgeHighRisk": true }'

11. What happened, from the record

curl -sS "$TETHER_URL/v1/actions?status=executed" -H "X-Api-Key: $BUSINESS_SECRET_KEY" \
  | jq -c '.actions[] | { actionId, tool, status, verification: .verification.status }'
curl -sS "$TETHER_URL/v1/audit?tool=cancel_booking" -H "X-Api-Key: $BUSINESS_SECRET_KEY" \
  | jq -c --arg id "$ACTION_ID" '[.events[] | select(.actionId == $id) | .event]'
{"actionId":"ba94a689-…","tool":"cancel_booking","status":"executed","verification":"verified"}
["proposed","approved","executed","verified"]

The audit reads proposed, approved, executed, verified, each naming the guest as subject. Had the booking read back unchanged, the action would be unverified, handed to a person, and your webhook would receive action.unverified and a conversation.handoff.

12. Handoff

A guest who asks for a person, in any of the four forms, gets the business's own contact message with no model call, and your webhook receives conversation.handoff naming the conversation and the guest:

curl -sS -X POST "$TETHER_URL/v1/chat" \
  -H "X-Api-Key: $BUSINESS_SECRET_KEY" -H "Content-Type: application/json" \
  -H "X-Tether-Customer-Token: $TOKEN" \
  -d '{ "prompt": "მინდა ადამიანთან საუბარი", "enableWriteActions": true }' -o handoff.json
jq -c .handoff handoff.json
{"trigger":"customer_request","message":"ვენიუს თანამშრომელი გიპასუხებთ აქვე ან დაგირეკავთ. A member of the venue's staff will reply here or call you.","language":"ka"}

The agent hands off the same way, with trigger agent, when it cannot help. See handoff.

13. The business's own documents

A business's policies, hours and FAQ are knowledge the agent retrieves beside your API, never in place of it: the policy says what the rule is, and the read says whether this booking qualifies.

{
  "name": "cancellation-policy",
  "format": "markdown",
  "text": "# ჯავშნის გაუქმების წესი\n\nჯავშნის გაუქმება ან შეცვლა უფასოა, სანამ ის დაიწყება. როცა სტუმრები უკვე დასხდნენ, ჯავშანს ასისტენტით ვეღარ შეცვლით. ექვსზე მეტი სტუმრის ჯგუფს მენეჯერის დასტური სჭირდება.\n\n# Cancellation policy\n\nA booking can be cancelled or changed free of charge until it starts. Once guests are seated it can no longer be changed through the assistant. Groups larger than six need the manager's approval."
}
curl -sS -X POST "$TETHER_URL/v1/knowledge" \
  -H "X-Api-Key: $BUSINESS_SECRET_KEY" -H "Content-Type: application/json" \
  -d @knowledge.json | jq -c '{ name, version, status }'
curl -sS -X POST "$TETHER_URL/v1/chat" \
  -H "X-Api-Key: $BUSINESS_SECRET_KEY" -H "Content-Type: application/json" \
  -H "X-Tether-Customer-Token: $TOKEN" -H "X-Tether-Explain: 1" \
  -d '{ "prompt": "რა არის ჯავშნის გაუქმების წესი?" }' -o policy-question.json
jq -c .meta.knowledge policy-question.json

Each answer that drew on a document names it in meta.knowledge.

14. Outcomes

The business reads its own report; you read every business's counts, and never a conversation, action or person:

curl -sS "$TETHER_URL/v1/outcomes" -H "X-Api-Key: $BUSINESS_SECRET_KEY" -o outcomes.json
jq -c '{ conversations: .outcomes.conversations.total, executed: .outcomes.actions.executed,
         verified: .outcomes.actions.verified, handedOff: .outcomes.conversations.handedOff,
         secondsToFirstVerifiedAction: .milestones.secondsToFirstVerifiedAction }' outcomes.json
curl -sS "$TETHER_URL/v1/provider/outcomes" -H "X-Api-Key: $PROVIDER_KEY" \
  | jq -c '[.businesses[] | { tenantId, conversations: .outcomes.conversations.total }]'
{"conversations":3,"executed":1,"verified":1,"handedOff":1,"secondsToFirstVerifiedAction":3.9}
[{"tenantId":2,"conversations":3}]

secondsToFirstVerifiedAction is the number the MVP is held to: from the business's creation to its first verified action, within a day. Cost reads 0 in mock mode. See outcomes.

What is provisional

These are defaults chosen so the platform can run, recorded for a person to confirm with the first providers. Each is configurable or a stated rule, and none is a promise.

DefaultWhere
A provider sees none of a business's conversations, actions, users or audit, and only the counts of its outcome reportProviders
A business runs on its provider's plan; billing is arranged directly, and cost is the ledger's estimate, not an invoiceOutcomes
Customer tokens live at most 3600 secondsCustomerTokens:MaxLifetimeSeconds
Sign-in links last 10 minutes, sessions 12 hours; at most 5 sign-in messages per person per business per windowUserSessions:*, Mail:MaxSignInMailsPerWindow
Staff mail is in Georgian or English, by the pack's languageMail
The verification read waits at most 5 seconds and is made onceAI:ActionGuardrails:VerificationTimeoutSeconds
An undeclared 4xx from your API on a confirmed write is a refusal; 408 and 429 let a person confirm againTools
Events are delivered at least once, six attempts over about a minute, deduplicated by request_idEvents
Latin-typed Georgian is answered in Latin letters unless the pack's prompts.latinGeorgianReply is georgianDomain pack
The Georgian text Tether writes itself, and its Georgian word lists, await review by a native speakerthe Tether repository

Trying it before your API is ready

The Tether repository runs this page against a stand-in booking API, a local SMTP sink and the API in mock mode, with nothing outside the machine:

docs/loops/mvp/follow-guide.sh

docs/loops/mvp/rehearse.sh booking and rehearse.sh shop go further: every refusal, race, expiry and handoff above, in all four language forms, for both stand-ins.