Tether
DocsGitHub

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()
}
StatusShow
ExecutedSuccess, with appliedChanges if it says something useful
ReplayedSuccess. 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
GuardRejectedOpen the risk gate; the acknowledgement was missing
GuardForbidden"You cannot approve this." Not retryable
Conflict"Already being processed." Do not retry
FailedRetryableShow the error, offer one retry
FailedTerminalShow 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.

Dismissal