Runlane
Recipes

Human-in-the-loop

Generate content, pause for approval, then publish or reject it.

Use a wait token when generated content needs an external decision. Runlane stores the token and resumes linked runs. Your application owns the review inbox, notification channel, authentication, authorization, and business audit.

Request content approval

The Runlane token and step calls below are real. The marked generation, notification, and publishing calls are pseudocode for your application and providers:

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

const approvalSchema = z.object({ approved: z.boolean() })
const contentSchema = z.object({ body: z.string(), title: z.string() })
const approvalResultSchema = z.object({ status: z.enum(['published', 'rejected', 'timed_out']) })

export const approveContent = task({
  id: 'content.approve',
  schema: z.object({ draftId: z.string() }),
  output: approvalResultSchema,
  async run({ draftId }, context) {
    const content = await context.step.run('generate-content', { output: contentSchema }, async ({ token }) => {
      //////////////////////////////////
      // MAKE YOUR API CALLS HERE
      //////////////////////////////////
      const result = await generateContentDraft({ draftId, idempotencyKey: token, signal: context.signal })

      return contentSchema.parse(result)
    })

    const tokenId = await context.step.run('request-approval', { output: tokenIdSchema }, async ({ token }) => {
      const result = await context.wait.createToken({
        idempotencyKey: token,
        meta: { draftId },
        timeout: '24h',
      })

      //////////////////////////////////
      // MAKE YOUR API CALLS HERE
      //////////////////////////////////
      await notifyContentReviewers({ content, idempotencyKey: token, tokenId: result.token.id })

      return result.token.id
    })

    const approval = await context.wait.getToken(tokenId, { output: approvalSchema })
    if (approval === undefined) throw new Error(`Token ${tokenId} was not found`)
    if (approval.status === WaitTokenStatus.Pending) {
      return context.wait.forToken(approval.id, { reason: 'content_approval_pending' })
    }
    if (approval.status === WaitTokenStatus.TimedOut) return { status: 'timed_out' }
    if (!approval.output.approved) return { status: 'rejected' }

    await context.step.run(
      'publish-content',
      { output: z.object({ published: z.literal(true) }) },
      async ({ token }) => {
        //////////////////////////////////
        // MAKE YOUR API CALLS HERE
        //////////////////////////////////
        await publishContent({ content, idempotencyKey: token, signal: context.signal })

        return { published: true }
      },
    )

    return { status: 'published' }
  },
})

context.wait.forToken() ends the attempt and frees the worker. After completion or timeout, a new attempt starts at the top and replays the completed generation and token-creation steps.

Make the notification idempotent. It runs before the enclosing step is committed and could be repeated after an uncertain failure.

Complete it from your application

Use an authenticated state-changing route to record the reviewer’s choice:

src/approvals/complete-content-approval.ts
import { ActorType, type RunlaneRuntime } from '@runlane/core'

interface PendingContentApproval {
  readonly tokenId: string
}

interface Reviewer {
  readonly id: string
}

export async function completeContentApproval(
  runlane: RunlaneRuntime,
  approval: PendingContentApproval,
  reviewer: Reviewer,
  approved: boolean,
) {
  return runlane.wait.completeToken(approval.tokenId, {
    actor: { id: reviewer.id, type: ActorType.Operator },
    output: { approved },
  })
}

The first terminal write wins. A repeated or competing completion returns the stored winner. Reconcile application state from that returned token, not from the submitted body.

Before production

  • Require authentication and authorization. For cookie-authenticated routes, require a CSRF token or an exact allowed Origin.
  • Treat a token id as an identifier, not a bearer secret.
  • Keep drafts and reviewer comments out of token metadata.
  • Keep maintenance running for timeouts and resume recovery.
  • Test duplicate completion, competing decisions, timeout, and unauthorized access.

The complete security and reconciliation flow is in build a human-in-the-loop flow.

On this page