Step-up for held actions

View as Markdown

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) 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

JavaScript
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)

TypeScript
// 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.

MCP tools and API routes

The MCP and API 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.

JavaScript
// 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.