# Step-up for held actions

When a rule holds an action for review, the person (or agent) waiting has two ways through: one of your team approves it in **Control › Approvals › Waiting**, or your own app checks that a real person is there and reports the result. This page is the second way: a **step-up check**, such as a passkey, a code from an authenticator app, a one-time code by email, or Turnstile.

A person who passes goes ahead at once. One who fails sees your message again, and the hold is closed as rejected. Telltale never sees the code, the passkey or anything the person typed: your app runs the check and sends only `passed` or `failed` and how it checked.

A passed step-up is evidence your check succeeded, not authorization. Always run your own authorization (the person may do this, the target still exists) before the write.

## How it works

1. Your backend checks the action with Telltale before it runs, as usual.
2. If the answer is a hold (`decision: review` with a `stepUp` hint), the adapter calls your `onHold` handler for this request.
3. Your handler looks at the request: did the person send a step-up proof (a fresh code, a passkey assertion, a Turnstile token)? It checks it with your own code and answers `passed` or `failed`. With no proof yet, it answers nothing.
4. The adapter reports the result to Telltale (POST /api/v1/decisions/{decisionId}/step-up). A pass turns the answer into `allow` for this request; nothing or a fail keeps it held.
5. When the answer is still a hold, your app shows the person the step-up prompt (see [What your customer sees](/docs/verdicts#what-your-customer-sees)) and sends the request again with the proof.

A hold can be settled for 15 minutes. Reporting the same result again within 60 seconds is safe (a retry after a dropped connection). A reviewer and a step-up cannot both settle one hold.

## Website actions: the server adapter

`guard()` is `verify()` plus your step-up. Give `onHold` once to `createTelltaleServer`, or per call.

### Express

```js
import express from 'express';
import {createTelltaleServer} from './lib/telltale/server.js';

const telltale = createTelltaleServer({
  endpoint: 'https://app.trytelltale.dev',
  serverKey: process.env.TELLTALE_SERVER_KEY,
  // Runs only when a check is held. Answer 'passed', 'failed', or nothing (no proof sent yet).
  onHold: async ({incoming}) => {
    const code = incoming.headers['x-confirm-code'];
    if (!code) return; // no proof yet: the app asks the person
    const ok = await verifyAuthenticatorCode(incoming.user, code); // your own check
    return {result: ok ? 'passed' : 'failed', method: 'mfa'};
  },
});

app.post('/api/reports/:id/export', requireSignedIn, async (req, res) => {
  const result = await telltale.guard({
    sessionToken: req.get('x-telltale-session'),
    actionId: 'export_report',
    targetId: req.params.id,
    incoming: req,
  });
  if (result.decision === 'allow') {
    if (!(await userMayExport(req.user, req.params.id))) return res.status(403).end(); // your rule, always
    return res.json(await exportReport(req.params.id));
  }
  if (result.decision === 'review') {
    return res.status(403).json({
      error: 'confirm_required',
      message: 'We need to confirm it’s you before we finish this. Please confirm with your passkey or a code from your authenticator app.',
      retry: result.stepUp?.result === 'failed' ? 'That code didn’t work. Try again.' : undefined,
    });
  }
  return res.status(403).json({error: 'not_available', message: 'We couldn’t complete this right now. If you think this is a mistake, contact our support team and we’ll sort it out.'});
});
```

The browser shows a code field on `confirm_required` and sends the same request again with the `X-Confirm-Code` header.

### Next.js (App Router)

```ts
// app/api/reports/[id]/export/route.ts
import {createTelltaleServer} from '@/lib/telltale/server.js';
import {getSession} from '@/lib/auth';

const telltale = createTelltaleServer({endpoint: 'https://app.trytelltale.dev', serverKey: process.env.TELLTALE_SERVER_KEY!});

export async function POST(request: Request, {params}: {params: Promise<{id: string}>}) {
  const {id} = await params;
  const user = await getSession(request);
  if (!user) return new Response(null, {status: 401});
  const result = await telltale.guard({
    sessionToken: request.headers.get('x-telltale-session') ?? '',
    actionId: 'export_report',
    targetId: id,
    // A passkey assertion the page collected with navigator.credentials.get() and sent along.
    onHold: async () => {
      const assertion = request.headers.get('x-passkey-assertion');
      if (!assertion) return;
      return {result: (await verifyPasskey(user, assertion)) ? 'passed' : 'failed', method: 'passkey'};
    },
  });
  if (result.decision === 'allow' && (await userMayExport(user, id))) return Response.json(await exportReport(id));
  if (result.decision === 'review') return Response.json({error: 'confirm_required'}, {status: 403});
  return Response.json({error: 'not_available'}, {status: 403});
}
```

What your handler receives: `decisionId`, `actionId`, `targetId`, `reason` (`behaviour_only_agent`, `edge_evidence`, `policy` or `backend`), `expiresAt` and the `incoming` request you passed. Never the verdict's reasons or scores. It answers `'passed'`, `'failed'`, `{result, method}` with method `passkey`, `mfa`, `turnstile`, `own_approval` or `other` (the default), or nothing. A handler that throws counts as nothing.

What `guard()` answers: the verify result, with `decision: 'allow'`, `heldDecision: 'review'` and `stepUp.result: 'passed'` after a pass; `decision: 'review'` with `stepUp.result: 'failed'` after a fail; the verify result unchanged otherwise. `requireAllowed()` runs the same step-up first when you gave `onHold`.

Without the adapter, report the result yourself: [Server API › Step-up for held actions](/docs/server#step-up-for-held-actions).

## MCP tools and API routes

The [MCP](/docs/mcp) and [API](/docs/api-channel) wrappers take the same `onHold` option. For an MCP tool the agent can't type a code, so the step-up is usually your own approval: ask the person in your app (a push notification, an in-app prompt) and answer `{result: 'passed', method: 'own_approval'}` when they confirm.

```js
// API middleware (Express): the step-up proof travels with the agent's request.
import {createTelltaleApi} from './lib/telltale/api.js';

const telltale = createTelltaleApi({
  endpoint: 'https://app.trytelltale.dev',
  serverKey: process.env.TELLTALE_SERVER_KEY,
  protect: ['POST /v1/invoices'],
  onHold: async ({req, accountId}) => {
    const code = req.headers['x-confirm-code'];
    if (!code) return;
    return {result: (await verifyAccountCode(accountId, code)) ? 'passed' : 'failed', method: 'mfa'};
  },
});
app.use(telltale.express()); // records requests; the route guard enforces protection
app.post('/v1/invoices', requireAuth, telltale.protect(), createInvoice);
```

## Who gets held, and why a way through matters

Rules hold actions for different reasons: your rule says **Hold for review**, an AI agent verdict of medium confidence met a **Deny** rule (held instead of denied by default), or edge evidence you forwarded raised **Allow** to a hold. Some of the people held are real people: someone using a screen reader or voice control, a kiosk, someone who paused for a long time. A step-up lets them finish what they came to do without waiting for your team, and without ever being told they look like a bot.

The rule editor's **What your customer sees** box links here and has the recommended messages to copy.
