Guides
Building a confirmation UI
Everything else in Tether is machinery. This screen is where the safety actually lives — it is the only place a human decides whether the model was right.
What you receive
Any turn that staged a write returns pending_actions alongside the blocks:
{
"action_id": "9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33",
"plan_id": "1a7f4d02-8e3b-4c15-9f6a-2d7e0b3c5a91",
"tool": "void_ticket",
"command_type": "void_ticket",
"summary": "void ticket — awaiting confirmation.",
"expires_at": "2026-08-20T20:41:07Z"
}
Render one card per entry, below the blocks.
A card
function PendingActionCard({ action, sessionId }) {
const [state, setState] = useState('idle')
const expiry = useCountdown(action.expires_at)
if (expiry.expired) {
return <Card tone="muted">This request has expired. Ask again to get a fresh one.</Card>
}
return (
<Card tone="action">
<h4>{titleFor(action.command_type)}</h4>
<p>{action.summary}</p>
<footer>
<span>Expires in {expiry.label}</span>
<button disabled={state !== 'idle'} onClick={() => setState('confirming')}>
Confirm
</button>
<button disabled={state !== 'idle'} onClick={dismiss}>Not now</button>
</footer>
{state === 'confirming' && (
<RiskGate action={action} onDone={(r) => setState(r)} />
)}
</Card>
)
}
The second gesture
A write registered elevated or high risk needs acknowledgeHighRisk: true, and that flag is
supposed to mean something happened:
function RiskGate({ action, onDone }) {
const [typed, setTyped] = useState('')
const phrase = 'VOID'
return (
<div className="risk-gate">
<p>This voids a live ticket. Type <strong>{phrase}</strong> to confirm.</p>
<input value={typed} onChange={(e) => setTyped(e.target.value)} />
<button
disabled={typed !== phrase}
onClick={() => confirm(action.action_id, { acknowledgeHighRisk: true }).then(onDone)}
>
Void ticket
</button>
</div>
)
}
A typed phrase, a held button, a manager PIN, a second person — any of these is a real gesture.
Handling the response
async function confirm(actionId, { acknowledgeHighRisk = false } = {}) {
const res = await fetch(`/api/assistant/actions/${actionId}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ acknowledgeHighRisk, idempotencyKey: `${actionId}:${attempt}` }),
})
return res.json()
}
| Status | Show |
|---|---|
Executed | Success, with appliedChanges if it says something useful |
Replayed | Success. It already ran — do not say "again" |
NotFound | "This request is no longer valid. Ask again." |
Expired | "This request expired. Ask again." |
Rejected | "This request was declined." |
AwaitingApprovers | "Your approval is recorded; another approver is needed." |
ApproverUnknown, ApprovalForbidden | "You cannot approve this." Sign in a permitted user |
Indeterminate | "We could not confirm whether this happened." Do not retry — reconcile |
GuardRejected | Open the risk gate; the acknowledgement was missing |
GuardForbidden | "You cannot approve this." Not retryable |
Conflict | "Already being processed." Do not retry |
FailedRetryable | Show the error, offer one retry |
FailedTerminal | Show errorMessage. No retry button |
The states that get missed
Expiry. Actions expire after a time set by their risk: 24 hours for low, 4 for elevated, 1
for high. Show the remaining time and disable the button when it passes, or your user clicks
Confirm and gets a 410 with no explanation.
Double-tap. Disable the button on the first click. Tether will not execute twice — the claim
guarantees that — but a second click returns Conflict, which looks like an error to someone who
just approved something successfully.
Navigation away. The action outlives the screen. GET /v1/actions lists what is still
pending for the tenant, so a user who reloaded can still find theirs. Send the acting person's
X-Tether-User-Id on the confirm.
A different user is looking at it. GET /v1/actions is tenant-scoped, and so is a
confirmation: who may approve is decided by your approval policy, not by who staged the action.
A manager can approve what a shift lead's conversation staged. If the acting user is not
registered, or their role may not approve that tool, the confirm answers ApproverUnknown or
ApprovalForbidden (403), so hide the button for those people rather than showing it and
refusing.
Never optimistic
// WRONG
onClick={() => { markVoided(ticket); confirm(action.action_id) }}
A confirmation can be rejected by the guard, lost to a race, refused by your own endpoint's permission model, or fail terminally. Optimistic UI here shows a user that something happened which did not.
Show a spinner. Wait for the status. It is one round trip on an action the user chose to take.