API reference
Actions
One endpoint stages a write, one lists the writes waiting, and confirm is the only thing in the system that can make one happen. Reject says no, and reconcile settles a write whose outcome is unknown.
Stage a write directly
POST /v1/actions
Stages a registered write without a chat turn, exactly as a model call would. Nothing reaches your endpoint until the action is confirmed. Tenant secret key only.
curl -X POST $TETHER_URL/v1/actions \
-H "X-Api-Key: $TETHER_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "tool": "cancel_order", "arguments": { "id": "A1" } }'
{
"status": "preview",
"toolName": "cancel_order",
"actionId": "8f1c…",
"confirmationToken": "tac_…",
"expiresAt": "2026-09-30T05:03:02Z",
"summary": "cancel order (id: A1) — awaiting confirmation.",
"resultJson": "{ … }"
}
actionId is what you pass to confirm. resultJson carries the same
preview as an escaped string and is kept for compatibility; read the top-level fields instead.
A tool that is not registered answers 404, a read tool or arguments missing a required
property answer 400, and actionId is null in each case.
A write that declares a precondition is checked before
it is staged. When your system says no, nothing is staged and the answer is 409 with status
precondition_failed; resultJson carries the reason, the check, and your read's result. When the
read cannot be made, the answer is 502 with status precondition_error, and nothing is staged
either.
List pending actions
GET /v1/actions?limit=50&scope=3&tool=cancel_order
Writes staged for this tenant and still awaiting a decision, newest first. With status, the
writes in that state instead, such as status=executed to see what
verification found.
| Parameter | Default | Notes |
|---|---|---|
limit | 50 | Clamped to 1–200. |
scope | — | Only actions staged under this scope id. |
tool | — | Only actions for this tool. |
status | pending | pending, executed, failed, rejected, expired, indeterminate or precondition_failed. |
{
"actions": [
{
"actionId": "9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33",
"tool": "void_ticket",
"commandType": "void_ticket",
"title": "void_ticket",
"rationale": null,
"arguments": { "ticket_id": "T-4417" },
"status": "pending",
"scopeId": 3,
"subject": "G1",
"sessionId": "5b0f3c2e-8a1d-4f6b-9e27-3c4d5e6f7a81",
"requestedByUserId": 431,
"createdAt": "2026-08-20T18:41:07Z",
"expiresAt": "2026-08-20T19:41:07Z",
"verification": null
}
],
"count": 1
}
sessionId is the conversation whose turn staged the write, or null when it was staged here
directly. verification is null until an executed write that declares
verify has been read back, and then has the shape described
under Verification.
The listing is scoped to the tenant, not to the user or session: one operator sees writes
another operator's turn staged. Who may approve is decided by your
approval policy, not by who staged the action, so an approval
queue can show every waiting write to the people allowed to act on it. commandType and tool
are the same value.
Confirm an action
POST /v1/actions/{actionId}/confirm
curl -X POST https://api.trytether.ge/v1/actions/9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33/confirm \
-H "X-Api-Key: $TETHER_SECRET_KEY" \
-H "X-Tether-User-Id: 431" \
-H "Content-Type: application/json" \
-d '{ "acknowledgeHighRisk": true, "idempotencyKey": "confirm-9c1e7b6a-attempt-1" }'
X-Tether-User-Id names the approver. With no users and no policy registered it is accepted as
stated; once you register users and a policy, the person must be registered and their role must
be allowed to approve that tool.
| Field | Type | Default | Notes |
|---|---|---|---|
acknowledgeHighRisk | boolean | false | Required for a write registered elevated or high risk, and for one whose declared value is at or above the threshold — or could not be read at all. |
idempotencyKey | string | — | Your own key. A repeat with a key that matched a prior successful execution returns that stored result and runs nothing. |
The request body may be omitted entirely, which is equivalent to sending both defaults.
What happens, in order
Replay check. If
idempotencyKeymatches a prior successful execution for this tenant and user, its stored result is returned asReplayed.Lookup. The action must exist, belong to this tenant, be pending, and be unexpired. An expired action answers
Expiredand is never claimed; a rejected one answersRejected. Actions also expire on a schedule, without anyone opening them, and the business is told once withaction.expired(see Events webhook).Who is approving. With users and a policy registered,
X-Tether-User-Idmust name an active registered user whose role may approve this tool, and a policy that needs several approvers holds the action atAwaitingApproversuntil enough different people have confirmed. With none registered, the id is accepted as stated.Handler resolution. The command type must still be a registered write in the current catalog.
The guard. Tenant boundary, then the risk acknowledgement.
The precondition. For a write that declares one, the read is called again, for the action's customer. If the check no longer holds, the action is settled as
PreconditionFailedand the write is never called. If the read fails, the write is not called and the action stays pending (PreconditionError).The claim. A compare-and-set flips
pending → executing. Losing it means another confirm won.Execution. Your endpoint is called with
"mode": "execute". Once, never retried. If it does not answer, the action becomesIndeterminateand waits for reconcile.Verification. For a write that declares
verify, once the execution is recorded, the read is called once, for the action's customer, and its answer is recorded asverifiedorunverified. It never calls the write again and never changes the status above.
Response
{
"status": "Executed",
"actionId": "9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33",
"commandType": "void_ticket",
"appliedChanges": { "voided": true, "ticket_id": "T-4417" },
"errorCode": null,
"errorMessage": null,
"verification": {
"status": "verified",
"reason": "get_ticket reports state = \"void\".",
"checkedAt": "2026-08-20T18:43:12Z",
"handoff": null
}
}
appliedChanges is whatever your endpoint returned, passed through unmodified.
Verification
verification is present on an Executed (and a Replayed) answer for a write that declares
verify, and null otherwise.
| Field | Meaning |
|---|---|
status | verified: your read showed the effect. unverified: it did not, or it failed, or it did not answer within a few seconds. |
reason | What the read reported, or why it could not be checked. |
checkedAt | When the read answered or gave up. |
handoff | On unverified only: { "trigger": "unverified_outcome", "sessionId": … }. A person has been handed the outcome, and sessionId names the conversation that staged the write, or is null. |
An unverified outcome is not a failed action. The write happened, the status is still
Executed, and nothing will run it again. Tell the customer that the request was sent and a
person will check it, not that it is done.
| Status | HTTP | Meaning | What to show |
|---|---|---|---|
Executed | 200 | Your endpoint ran it and returned success | Done, unless verification.status is unverified: then "Sent; a person will check it" |
Replayed | 200 | The key matched a prior success; nothing ran again | Done |
Rejected | 200 | The action was rejected, now or earlier; it can never be confirmed | "This request was declined" |
AwaitingApprovers | 202 | Your approval is recorded, but the policy needs more distinct approvers. approversRequired and approversReceived say how many | "Waiting for another approver" |
NotFound | 404 | Unknown, already executed, or staged for a different tenant | "This request is no longer valid — ask again" |
Expired | 410 | The confirmation window passed; the action is gone | "This request expired — ask again" |
ApproverUnknown | 403 | No X-Tether-User-Id, or not an active registered user, while a policy is in force | Sign the approver in as a registered user |
ApprovalForbidden | 403 | The user's role may not approve this tool under your policy | "You cannot approve this" |
Prohibited | 451 | Categorically refused; no approver or credential clears it | Show errorMessage, do not offer a retry |
Indeterminate | 409 | Your endpoint was called and did not answer, so the write may or may not have happened | Do not retry; reconcile |
PreconditionFailed | 409 | The write's precondition no longer held when it came to execute. The write was never called and the action is settled; confirming again answers the same | Show errorMessage; the customer asks again if their situation changes |
PreconditionError | 503 | The precondition's read failed or did not answer. The write was not called and the action is still pending | Offer to confirm again later |
ProviderRefused | 409 | The write was sent to your imported API, which refused it without applying it. errorMessage carries your status, code and reason. The action is settled; confirming again answers the same | Show errorMessage; the customer asks again if their situation changes |
GuardForbidden | 400 | Wrong tenant, or your authorizer refused | "You cannot approve this" |
GuardRejected | 400 | Risk acknowledgement missing | Re-prompt with the deliberate gesture |
Conflict | 400 | Another confirm is already executing this action | "Already in progress" — do not retry |
HandlerMissing | 400 | The command is not a registered write in the current catalog | An integration bug: the catalog changed underneath the action |
FailedRetryable | 400 | Transient failure; the action was released back to pending | Offer a retry, bounded to 3 total attempts |
FailedTerminal | 400 | Terminal failure; the action is consumed | Show errorMessage, do not offer a retry |
Error codes
errorCode | With status |
|---|---|
PENDING_ACTION_NOT_FOUND | NotFound |
action_expired | Expired |
action_rejected | Rejected |
approver_unknown | ApproverUnknown |
approver_forbidden | ApprovalForbidden |
awaiting_approvers | AwaitingApprovers |
action_prohibited | Prohibited |
EXECUTION_INDETERMINATE | Indeterminate |
precondition_failed | PreconditionFailed |
precondition_error | PreconditionError |
provider_refused | ProviderRefused |
HANDLER_NOT_REGISTERED | HandlerMissing |
AI_ACTION_REQUIRES_CONFIRMATION | GuardRejected |
ACTION_ALREADY_IN_PROGRESS | Conflict |
EXECUTION_FAILED_RETRYABLE | FailedRetryable |
EXECUTION_ATTEMPTS_EXHAUSTED | FailedTerminal, after 3 attempts |
EXECUTION_FAILED, VALIDATION_ERROR, RESOURCE_NOT_FOUND, FORBIDDEN, CONFLICT | FailedTerminal |
user_token_required | FailedTerminal: the imported API requires the signed-in person's own token and the confirm carried none. The API was not called |
ACTION_NOT_INDETERMINATE | Conflict, from reconcile on an action that is not indeterminate |
Reject an action
POST /v1/actions/{actionId}/reject
curl -X POST $TETHER_URL/v1/actions/9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33/reject \
-H "X-Api-Key: $TETHER_SECRET_KEY" \
-H "X-Tether-User-Id: 431" \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer withdrew the request" }'
The same identity rule as confirm: with a policy in force the user must be an active registered
user, but no role is needed, because a no is always allowed. The answer is 200 with status
Rejected. A rejected action can never be confirmed afterwards; confirming it again answers
Rejected, not an error.
Reconcile an action
POST /v1/actions/{actionId}/reconcile
For an action whose status is Indeterminate: your endpoint was called and did not answer. This
sends the write again under the original idempotency key, which is safe because your endpoint
claims that key. It either replays its first result or performs the write for the first time, and
cannot perform it twice. Tenant secret key only; a confirmation token cannot reconcile. If the
answer is still ambiguous the action stays Indeterminate, so reconciling twice is safe.
On an action that is not indeterminate it answers 400 with status Conflict and code
ACTION_NOT_INDETERMINATE, and calls nothing.
The risk acknowledgement
{
"status": "GuardRejected",
"actionId": "9c1e7b6a…",
"commandType": "void_ticket",
"errorCode": "AI_ACTION_REQUIRES_CONFIRMATION",
"errorMessage": "'void_ticket' is registered as a high-risk write. Re-submit the confirmation with acknowledgeHighRisk = true to proceed."
}
Expiry
A staged action expires after a time set by its declared risk: 24 hours for low, 4 hours for
elevated, 1 hour for high. A proposal made against a picture of the world that has since moved
on should not be confirmable. Confirming after the deadline answers 410 with status Expired
and never executes.
expiresAt comes back when the write is staged and in the listing. Show the remaining time on
the card, and stop offering the button when it passes.
Related
- Staged writes — the lifecycle in full
- Building a confirmation UI
- Your first write