Runlane
Build with RunlaneBuild tasks

Build a human-in-the-loop flow

Pause for a durable decision and resume from an authenticated app route.

Use a wait token when a run needs one external result: an approval, rejection, edit, or operator choice.

Runlane ownsYour application owns
Token state, timeout, first-terminal-write-wins completion, and run resumeReview records, assignments, UI, authentication, authorization, and business audit

The examples below include both the waiting task and the server-side completion call. Your application still owns the review UI and access policy.

Store the business record first

Create an application record with the document id, reviewer, display data, and business status. Save the Runlane token id on this record after the task creates it.

Do not use runlane.wait.listTokens() as an approval inbox. It filters by token status and creation time. Its summaries omit metadata and output.

Keep token metadata small. Store identifiers, not documents, prompts, or other sensitive bodies.

Create the token once

Create the token inside a durable step. Use the step token as the wait-token idempotency key:

src/tasks/review-document.ts
import { task, tokenIdSchema, WaitTokenStatus } from '@runlane/core'
import * as z from 'zod'

const reviewDecisionSchema = z.object({ decision: z.enum(['approve', 'reject']) })
const reviewSchema = z.object({ documentId: z.string() })

export const reviewDocument = task({
  id: 'documents.review',
  schema: z.object({ reviewId: z.string() }),
  async run({ reviewId }, context) {
    //////////////////////////////////
    // MAKE YOUR API CALLS HERE
    //////////////////////////////////
    const review = reviewSchema.parse(await loadReview({ reviewId, signal: context.signal }))

    const tokenId = await context.step.run('create-review-token', { output: tokenIdSchema }, async ({ token }) => {
      const result = await context.wait.createToken({
        idempotencyKey: token,
        meta: { documentId: review.documentId, reviewId },
        timeout: '7d',
      })
      return result.token.id
    })

    //////////////////////////////////
    // MAKE YOUR API CALLS HERE
    //////////////////////////////////
    await saveTokenId({ reviewId, tokenId })

    const decision = await context.wait.getToken(tokenId, { output: reviewDecisionSchema })
    if (decision === undefined) throw new Error(`Token ${tokenId} was not found`)
    if (decision.status === WaitTokenStatus.Pending) {
      return context.wait.forToken(decision.id, { reason: 'human_review_pending' })
    }

    //////////////////////////////////
    // MAKE YOUR API CALLS HERE
    //////////////////////////////////
    if (decision.status === WaitTokenStatus.TimedOut) {
      await saveReviewTimeout({ reviewId })
    } else {
      await saveDecision({ decision: decision.output.decision, reviewId })
    }
  },
})

The task follows this path:

  1. Load the application review.
  2. Replay or create its Runlane token.
  3. Save the token id on the application record.
  4. Read the token with an output schema.
  5. Release while it is pending.
  6. Apply a completed decision or handle timeout.

context.wait.forToken() ends the attempt. It does not keep a function or worker alive. The resumed attempt starts from the beginning and replays the completed creation step.

Make saveTokenId() and saveDecision() idempotent. They sit outside the durable step and may run again.

Choose timeout and retention separately

timeout controls how long a token may remain pending. Omit it when the decision has no deadline.

idempotencyKeyTTL controls how long the same key keeps returning a terminal token. If omitted, ownership remains until that token is pruned.

Useful starting ranges are:

DecisionTypical timeout
Machine callback5m to 1h
Human review1d to 7d

Use the actual business deadline rather than copying these values blindly.

Complete the token from an app route

Use POST or another state-changing method. Authenticate the reviewer, authorize access to this review, and check current business state. For cookie-authenticated routes, require a CSRF token or require the Origin header to exactly match an allowed application origin.

src/reviews/complete-review.ts
import { ActorType, type RunlaneRuntime } from '@runlane/core'

interface PendingReview {
  readonly id: string
  readonly tokenId: string
}

interface Reviewer {
  readonly id: string
}

export async function completeReview(
  runlane: RunlaneRuntime,
  review: PendingReview,
  reviewer: Reviewer,
  input: { readonly comment: string; readonly decision: 'approve' | 'reject'; readonly intentId: string },
) {
  return runlane.wait.completeToken(review.tokenId, {
    actor: { id: reviewer.id, type: ActorType.Operator },
    meta: { intentId: input.intentId, reviewId: review.id },
    output: input,
  })
}

Persist an idempotent decision intent before this call when the application needs a stronger audit or recovery path.

Completion is first-terminal-write-wins. A repeat returns already_resolved with the stored completed or timed-out token. Reconcile the application record from that returned token, not from the request body.

Runlane records the actor supplied by your server. It cannot prove that the actor was authenticated. That remains the app's responsibility.

Secure the review flow

  • Treat the token id as an identifier, not a bearer credential.
  • Authorize both reading and completing the review.
  • Escape all document titles, comments, and model output.
  • Use a restrictive content security policy.
  • Send Cache-Control: private, no-store on review responses.
  • Never complete a token from a GET request.

Operate and test it

Keep maintenance running. It makes timeouts terminal and resumes token-linked runs in bounded batches. The small resume attempt after completion is only a latency improvement.

Test these cases:

  • retrying the task reuses the same token;
  • an unauthorized reviewer cannot read or complete the review;
  • a cross-site request fails its CSRF or exact-origin check;
  • repeated and competing decisions use the stored winner;
  • a timed-out token cannot later complete;
  • the task restarts and replays token creation;
  • untrusted content is escaped and never cached;
  • maintenance resumes links left after eager completion.

Prune terminal tokens separately from terminal runs. A token is eligible only when it is terminal and no retained run links remain.

On this page