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
| Step | Who | Credential |
|---|---|---|
| 1. Create your tenant | The operator of the Tether deployment | Operator key |
| 2. Describe your API | You | Your tenant's secret key, tth_ |
| 3. Set your template | You | Your tenant's secret key |
| 4. Grant provider authority | The operator | Operator key |
| 5. Create a business | You | Your provider key, tpv_ |
| 6. Register the business's staff | You, for the business | The business's secret key |
| 7. Mint customer tokens | Your platform's backend, for each business | The business's customer-token key, tck_ |
| 8. Embed the agent | Your platform's pages | The business's publishable key, tpk_ |
| 9. Approve in the inbox | The business's staff | Your inbox key, tib_, on the inbox host |
| 10. Read the outcomes | You and the business | Provider 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" } }
}
}
| Field | What it declares |
|---|---|
is_write | Required on every operation. A write is staged, shown to a person and executed only when confirmed. See reads and writes |
risk | low, elevated or high on a write: how long it may wait, and what the policy can key on. See choosing risk levels |
requires_customer | The 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 |
precondition | A 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 |
verify | A 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 |
outcomes | Optional: 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_writeas in step 2, and the CLI writestether.declarations.jsonin the current directory, never over an existing one, with everyis_writenull. Nothing is registered. Whether an operation writes, and its risk, precondition and verify, are still yours to declare: fill it in (it becomesdeclarations.jsonabove) and import it with the credential as in step 2. To keep the commit with the import, send thesourcethe 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 laterPUT /v1/domain-packreplaces 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:
| Field | Used for |
|---|---|
apiKey | The business's secret key: its staff, its knowledge, its outcome report, server-side turns |
signingSecret | Verifying the events Tether sends your webhook for this business |
customerTokenKey | Signing 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.
| Default | Where |
|---|---|
| A provider sees none of a business's conversations, actions, users or audit, and only the counts of its outcome report | Providers |
| A business runs on its provider's plan; billing is arranged directly, and cost is the ledger's estimate, not an invoice | Outcomes |
| Customer tokens live at most 3600 seconds | CustomerTokens:MaxLifetimeSeconds |
| Sign-in links last 10 minutes, sessions 12 hours; at most 5 sign-in messages per person per business per window | UserSessions:*, Mail:MaxSignInMailsPerWindow |
Staff mail is in Georgian or English, by the pack's language | |
| The verification read waits at most 5 seconds and is made once | AI:ActionGuardrails:VerificationTimeoutSeconds |
An undeclared 4xx from your API on a confirmed write is a refusal; 408 and 429 let a person confirm again | Tools |
Events are delivered at least once, six attempts over about a minute, deduplicated by request_id | Events |
Latin-typed Georgian is answered in Latin letters unless the pack's prompts.latinGeorgianReply is georgian | Domain pack |
| The Georgian text Tether writes itself, and its Georgian word lists, await review by a native speaker | the 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.