<!--
Trigger.dev Worker Pattern Playbook

Part of the free Claude Code template library from BILT AI — https://biltai.io/claude-templates

These are the actual playbooks used to build and ship production SaaS apps with
Claude Code. They're written to be handed to an agent as-is: point Claude at one
and it has the full pattern, the schema, the gotchas, and the "don't do this"
list. Paths, project refs, and org names have been replaced with placeholders
(<your-app>, <supabase-project-ref>, <your-github-org>) — search for "<" and
fill in your own before running.

Free to use, modify, and ship. No attribution required.
-->

# Trigger.dev Worker Pattern Playbook

**Portable spec for shipping any long-running task (>60s wall time) as a Trigger.dev v4 worker — never browser-bound, never `/api/generate`-bound.**

Built from the BKE / Kompozy implementation (universal-Trigger.dev migration 2026-04-27). Reference repo: `reference app`.

---

## 0. The universal rule

**Any flow whose total wall time can exceed ~60s MUST be a Trigger.dev worker.** No exceptions. This includes:

- Anything that calls a slow provider (HeyGen poll loops, DALL-E batches, Whisper, video composite).
- Anything that runs server-side ffmpeg.
- Anything that downloads >10MB from a provider.
- Anything that chains 3+ provider calls.

Browser-bound generation paths and `/api/generate`-style routes that await long providers are **banned**. Tab close mid-render orphans the pipeline row, burns credit, and silently corrupts state. The pattern below eliminates that entire class of bug.

---

## 1. Use this spec

**Inputs:**
- A `<kind>` slug for the new task (kebab-case, e.g. `render-blog`, `transcribe-audio`, `score-clips`).
- The provider chain the task runs (HeyGen, OpenAI, Anthropic, ffmpeg, etc.).
- Credit cost (if paid) — or "no charge" for free / regen flows.
- Maximum expected wall time (sets `maxDuration`).
- Whether the worker needs native modules (ffmpeg, ONNX, sharp, etc.) — drives `trigger.config.ts` build extensions.

**Outputs:**
- Three new files: `src/trigger/<kind>.ts` + `src/app/api/render/<kind>/route.ts` + `src/lib/render/<kind>-bridge.ts`.
- Pipeline page wired with mount-time orphan recovery for the new `<kind>`.
- `trigger.config.ts` updated if new native deps / runtime files.
- Trigger.dev redeployed after merge.

---

## 2. Architecture

```
src/
├── trigger.config.ts                          # Project ref, maxDuration, build extensions
│
├── trigger/
│   ├── <kind>.ts                              # The worker — wraps body in refund-on-failure
│   └── audit-orphan-charges.ts                # Hourly cron — final safety net
│
├── app/api/
│   ├── render/<kind>/route.ts                 # Dispatch: auth + charge credit + tasks.trigger
│   └── jobs/[runId]/route.ts                  # Generic status proxy (one file, all workers)
│
└── lib/
    ├── dispatch/
    │   ├── trigger-and-record.ts              # Writes trigger_runs ownership row
    │   ├── trigger-with-credit.ts             # Helper: charge + trigger (paid flows)
    │   └── trigger-without-credit.ts          # Helper: trigger only (regen / free flows)
    ├── render/
    │   ├── <kind>-bridge.ts                   # Browser-side: POST dispatch + poll status
    │   ├── worker-poll.ts                     # Shared 4s polling loop, 32-min ceiling
    │   └── persist-worker-output.ts           # Worker self-heal: INSERT-on-missing gc row
    └── credits/
        └── refund.ts                          # Idempotent refundCreditOnTaskFailure
```

---

## 3. The three-file pattern

### 3.1 Worker — `src/trigger/<kind>.ts`

```ts
import { task, logger } from '@trigger.dev/sdk';
import { refundCreditOnTaskFailure } from '@/lib/credits/refund';
import { persistWorkerOutputToRow } from '@/lib/autopilot/persist-worker-output';
import { createAdminSupabase } from '@/lib/supabase-server';

export const render<Kind>Task = task({
    id: 'render-<kind>',
    maxAttempts: 1,            // never auto-retry — burns provider credits
    maxDuration: <seconds>,    // tight ceiling per format
    run: async (payload, ctx) => {
        try {
            // 1. Hydrate inputs (ref assets from Storage, etc.) — service-role only
            // 2. Provider call(s)
            // 3. persistMediaToStorage(...) — NEVER store provider URLs
            // 4. persistWorkerOutputToRow(...) with PersistRecoveryContext
            //    so the worker can self-heal if the parent row never landed
            //    (Vara incident 2026-05-05 — bridge died, row never persisted,
            //    8 paid renders silently lost without this).
            return { /* structured output for the bridge */ };
        } catch (e) {
            await refundCreditOnTaskFailure({
                userId: payload.userId,
                taskRunId: ctx.run.id,
                format: '<kind>',
                amount: <cost>,
                reason: (e as Error)?.message,
            });
            throw e;
        }
    },
});
```

**Hard rules inside the worker:**
- `maxAttempts: 1` — retries burn provider credits twice.
- Wrap the entire body in `try { ... } catch { refund; throw }`. Refund is idempotent on `taskRunId` via the `credit_refunds` unique index.
- Use `createAdminSupabase()` (service role). Workers have no browser session — `auth.getUser()` will fail.
- Stream large media to `/tmp` via `Readable.fromWeb(res.body).pipe(createWriteStream(...))`. NEVER `await res.arrayBuffer()` — 100MB+ MP4s OOM the worker (lab note 2026-04-25 persona-frames).
- Always pass a `PersistRecoveryContext` (userId, workspaceId, outputType, engineOutput, source) to `persistWorkerOutputToRow` — without it, "row not found" returns silently and you lose the output.

### 3.2 Dispatch route — `src/app/api/render/<kind>/route.ts`

```ts
import { z } from 'zod';
import { triggerWithCredit } from '@/lib/dispatch/trigger-with-credit';

export const runtime = 'nodejs';
export const maxDuration = 60;  // dispatch is fast — auth + charge + trigger only

const BodySchema = z.object({
    // Real required inputs:
    prompt: z.string().min(1),
    // Recovery-context fields — ALWAYS .optional():
    workspaceId: z.string().uuid().optional(),
    queueItemId: z.string().optional(),
    outputType: z.string().optional(),
});

export async function POST(req: Request) {
    return triggerWithCredit({
        req,
        rateLimitBucket: 'generate',
        taskId: 'render-<kind>',
        format: '<kind>',
        creditCost: <number>,
        bodySchema: BodySchema,
        buildPayload: (parsed, userId) => ({ ...parsed, userId }),
    });
}
```

**`triggerWithCredit` vs `triggerWithoutCredit`:**
- `triggerWithCredit` — paid generation. Auth → rate-limit → charge credit → `triggerAndRecord` → `{ runId, publicAccessToken }`. If the trigger throws, refunds the spend synchronously.
- `triggerWithoutCredit` — regen flows + free flows. Auth → rate-limit → `triggerAndRecord`. No credit hop, no refund concern. Used by `src/app/api/render/regen-image/route.ts` and friends.

**Charge in the dispatch BEFORE `tasks.trigger`**, never inside the worker. A failed dispatch won't trigger anything, but a charge-inside-worker pattern leaves an orphan refund window if the worker crashes before the spend lands. The credit_transactions FK is soft (migration 00035) so the debit survives even if the parent gc row never materializes.

### 3.3 Browser bridge — `src/lib/render/<kind>-bridge.ts`

```ts
import { pollWorkerUntilDone } from '@/lib/render/worker-poll';

export async function tryRender<Kind>ViaWorker(args: {
    prompt: string;
    workspaceId: string;
    queueItemId: string;
    onStage?: (label: string) => void;
    onRunId?: (runId: string) => void;
}): Promise<{ ok: true; data: <ResultShape> } | { ok: false; error: string }> {
    // 1. POST /api/render/<kind> → { runId, publicAccessToken }
    const res = await fetch('/api/render/<kind>', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(args),
    });
    if (!res.ok) return { ok: false, error: (await res.json()).error };
    const { runId } = await res.json();

    // 2. Persist runId IMMEDIATELY — closes the bridge-orphan window
    args.onRunId?.(runId);

    // 3. Poll every 4s, 32-min ceiling
    const poll = await pollWorkerUntilDone(runId, { onStage: args.onStage });
    if (!poll.ok) return { ok: false, error: poll.error };

    return { ok: true, data: poll.output };
}

export async function resume<Kind>FromRunId(runId: string, onStage?: (label: string) => void) {
    // Called from pipeline page mount-time orphan recovery.
    // Reads the persisted runId from the pipeline row and re-attaches polling.
    return pollWorkerUntilDone(runId, { onStage });
}
```

**Why `onRunId?.(runId)` is mandatory:** the pipeline page persists `workerRunId` + `workerKind` to the pipeline row the instant the bridge gets a runId back. If the user closes the tab two seconds later, mount-time orphan recovery on the next visit picks up the runId from the row, calls `resume<Kind>FromRunId`, and re-attaches polling. Without this, the worker still finishes — but the row is permanently stranded on "Generating" because no client knows where the output lives. (Vara lab note 2026-05-05.)

---

## 4. Worker self-heal (Vara incident pattern)

The worker MUST be able to materialize the parent pipeline row if it's missing at write time. Reference: `src/lib/autopilot/persist-worker-output.ts`.

```ts
await persistWorkerOutputToRow({
    queueItemId: payload.queueItemId,
    output: { contentBody, mediaStoragePaths, ... },
    // PersistRecoveryContext — REQUIRED for self-heal:
    recovery: {
        userId: payload.userId,
        workspaceId: payload.workspaceId,  // single-workspace fallback inside the helper
        outputType: '<kind>',
        engineOutput: payload.engineOutput,
        source: 'worker:<kind>',
    },
});
```

If the row doesn't exist (browser tab died before `flushPersistImmediate(snap)` landed), the helper INSERTs it from the worker's output. Without recovery context, the helper returns "row not found" silently and the user loses the work.

**Do not trust `flushPersistImmediate` alone.** It closes the same-page tab-switch orphan (lab note 2026-04-29) but not the bridge-orphan (tab close mid-poll, lab note 2026-05-05). The worker's self-heal is the durability guarantee; the browser flush is a UX accelerator.

---

## 5. Generic status endpoint — [/api/jobs/[runId]/route.ts](file:///<reference-app>/src/app/api/jobs/[runId]/route.ts)

One file serves every worker. Bridges all poll this endpoint at 4s intervals. Key design points:

- **Auth gate:** `requireUser` proxies through Supabase session. Cross-tenant runId probes 404.
- **Ownership check:** reads `trigger_runs(run_id, user_id)`. Every dispatch site MUST `recordTriggerRun(handle.id, userId, taskId)` immediately after `tasks.trigger` — wrapped via `triggerAndRecord` so it's unskippable. Forgotten ownership row = 404 forever, worker runs in the dark, user billed (lab note 2026-05-11).
- **No SDK to the browser:** `TRIGGER_SECRET_KEY` never ships client-side. The browser only sees `{ id, status, output, error }`.
- **Fail-closed 404:** missing ownership row returns the same 404 as nonexistent runId — doesn't double as an existence oracle.

ESLint guard bans direct `@trigger.dev/sdk` `tasks` imports outside `src/lib/dispatch/` to force every dispatch through `triggerAndRecord`.

---

## 6. trigger.config.ts — build extensions

Reference: `trigger.config.ts`.

```ts
import { defineConfig } from '@trigger.dev/sdk';
import { syncVercelEnvVars, ffmpeg, additionalFiles, additionalPackages }
    from '@trigger.dev/build/extensions/core';

export default defineConfig({
    project: process.env.TRIGGER_PROJECT_REF || 'proj_xxx',
    dirs: ['./src/trigger'],
    maxDuration: 1800,  // 30 min default ceiling; per-task overrides via task({ maxDuration })
    retries: { /* ... */ },
    build: {
        extensions: [
            syncVercelEnvVars(),       // Syncs Vercel env vars into the Trigger.dev container
            ffmpeg(),                  // System ffmpeg — sets FFMPEG_PATH + FFPROBE_PATH
            additionalPackages({ packages: ['@resvg/resvg-wasm'] }),  // Native modules
            additionalFiles({
                files: [
                    'src/lib/render/fonts/*.ttf',                          // Inter / branded fonts
                    'node_modules/@resvg/resvg-wasm/index_bg.wasm',        // SVG → PNG in worker
                    'node_modules/onnxruntime-web/dist/*.wasm',            // Face detection
                ],
            }),
        ],
    },
});
```

**Critical env vars in Trigger.dev's project env (NOT just Vercel):**
- `TRIGGER_SECRET_KEY` — set by Trigger.dev itself.
- `NEXT_PUBLIC_APP_URL=https://your-app.vercel.app` — workers can't discover the Vercel URL; without this, `fetch(buildAppUrl('/api/x'))` falls back to `localhost:3001` and dies (memory: bke_trigger_app_url.md).
- `PUBLISH_WORKER_SECRET` (or equivalent) — internal-call header so worker → API hops bypass `requireUser` (memory: bke_publish_worker_secret.md).
- Service-role Supabase key.
- Provider keys (OpenAI, HeyGen, Anthropic, etc.).

`syncVercelEnvVars` covers the bulk on deploy; `NEXT_PUBLIC_APP_URL` and worker secrets must be set explicitly on Trigger.dev's side.

---

## 7. Mount-time orphan recovery (pipeline page wiring)

Reference: search `src/app/dashboard/pipeline/page.tsx` for `kind === 'persona-short'`.

```ts
// On pipeline page mount, scan all rows in 'Generating' state with a persisted workerRunId.
// For each, re-attach polling via resume<Kind>FromRunId.
useEffect(() => {
    for (const row of queue) {
        if (row.status !== 'Generating' || !row.workerRunId || !row.workerKind) continue;
        switch (row.workerKind) {
            case 'text':           void resumeTextFromRunId(row.workerRunId, ...); break;
            case 'image':          void resumeImageFromRunId(row.workerRunId, ...); break;
            case 'persona-short':  void resumePersonaShortFromRunId(row.workerRunId, ...); break;
            // ... one branch per <kind>
        }
    }
}, [/* mount only */]);
```

Every new `<kind>` needs:
1. A `resume<Kind>FromRunId` export in its bridge file.
2. A new branch in this switch.

Skip either and tab-close mid-render = stuck row forever.

---

## 8. Auto-deploy rule (mandatory)

From the global session-scoped-commits rule: **Trigger.dev workers and Vercel are separate runtimes.** Vercel deploys do NOT push workers. Pushing to master ships Next.js with today's code while the Trigger.dev container keeps running yesterday's. The `metadata.copy = "—"` bug (2026-05-14) burned hours because the fix shipped to Vercel but never to Trigger.

After `git push origin master` lands, run:

```bash
npx trigger.dev@<MATCHING_VERSION> deploy
```

Pin to the installed `@trigger.dev/sdk` version (read `package.json`), NOT `@latest` — the CLI aborts on version mismatch. Run when ANY of these are true:

- A file under `trigger/`, `src/trigger/`, or `trigger.config.*` changed.
- A file outside those paths changed AND at least one `src/trigger/**` file imports from it (directly or transitively). Verify with `grep -rn "from '@/lib/<path>'" src/trigger/` — non-empty = redeploy required.

**Common worker-imported `src/lib/` paths that REQUIRE a Trigger.dev redeploy when changed** (illustrative — grep first when unsure):

- `src/lib/autopilot/` — `persist-worker-output.ts`, `dispatch-server.ts`, `fan-out-raw.ts`
- `src/lib/credits/` — `refund.ts`, `post-spend-hooks.ts`, `pricing.ts`
- `src/lib/render/` — `server-write-generated.ts`, `credits.ts`, `captions.ts`, `fonts.ts`, `face-detect.ts`, `storage-upload.ts`, `whisper.ts`, `clip-scoring.ts`, `satori-base.ts`, `resvg-wasm-loader.ts`, `speaker-track.ts`, `hyperframes-components.ts`, `gen-video.ts`
- `src/lib/publish/` — `blotato-stage*.ts`, `resolve-target.ts`, `platform-limits.ts`, `wire-platform.ts`
- `src/lib/compliance/`, `src/lib/generators/`
- `src/lib/media-persist.ts`, `src/lib/image-gen.ts`, `src/lib/admin.ts`
- `src/lib/repo/generated-content.ts`, `src/lib/repo/platform-connections.ts`

Default action when uncertain: redeploy. Skipping ships invisible worker drift.

---

## 9. The audit-orphan-charges cron (final safety net)

Reference: `src/trigger/audit-orphan-charges.ts`.

Hourly cron that sweeps `credit_transactions` rows where `generated_content_id IS NULL` and `created_at < now() - interval '30 minutes'`:

1. Look up the user's gc rows in the time window (worker self-heal may have populated it).
2. If a match exists by user + time window + format → backfill the FK.
3. If no match → issue a refund via the same idempotent `refundCreditOnTaskFailure`.

This is the third layer of defense behind (a) `flushPersistImmediate` on the browser, (b) worker self-heal via `persistWorkerOutputToRow`. By the time the cron sees an orphan charge, both upstream layers have failed — the cron makes sure no user ever ends up paying for a row that never materialized.

---

## 10. Common pitfalls

**Don't await long-running provider calls in `/api/generate`-style routes.** Tab close orphans the row, Vercel function caps at 300s, user gets charged for nothing. The universal rule (§0) exists for this — if wall time can exceed 60s, it's a worker.

**Don't charge credit inside the worker — charge in dispatch BEFORE `tasks.trigger`.** A failed dispatch won't trigger anything (no orphan). A charge-inside-worker pattern leaves a refund-window if the worker crashes pre-spend; worse, retries double-charge.

**Don't trust `flushPersistImmediate` to save the row before the worker runs.** It closes the same-page-tab-switch orphan, not the bridge-orphan (tab close mid-poll). Workers MUST self-heal via `PersistRecoveryContext` — see §4. (Vara lost 8 paid blog renders to exactly this gap, 2026-05-05.)

**Don't skip the Trigger.dev redeploy when a worker-imported lib file changes.** Workers run yesterday's code while Next.js ships today's. The `metadata.copy = "—"` bug (2026-05-14) burned hours. Default: redeploy.

**Don't store `arrayBuffer()` of large media in worker memory.** Stream to `/tmp` via `Readable.fromWeb(res.body).pipe(createWriteStream(path))`. 100MB+ MP4s OOM the worker (lab note 2026-04-25 persona-frames). Pair with `-threads 2` on ffmpeg to cap working set.

**Don't tighten dispatch-route Zod schemas to require recovery-context fields.** `workspaceId`, `queueItemId`, `outputType`, `engineOutput` MUST stay `.optional()`. Server-side dispatch helpers (autopilot, RSS, regen) often don't thread every field. Hard-validation turns missing-data — the exact condition recovery exists for — into a 400. (BKE 2026-05-05 blog dispatch 400 cascade.)

**Don't forget to set `NEXT_PUBLIC_APP_URL` in Trigger.dev's env.** Workers can't discover the Vercel URL. Without it, `fetch(buildAppUrl('/api/x'))` falls back to `localhost:3001` and fails. Set it in Trigger.dev project env, not just Vercel.

**Don't put cross-user file paths through worker downloads without guarding the userId prefix.** Workers use the service-role client, which bypasses RLS. Any `storagePath` accepted from a dispatch payload must have its first segment validated against the authenticated `userId`. Reject otherwise. (Pattern in `regen-tweet-overlay` and `regen-image` after lab note 2026-04-27.)

**Don't bypass `triggerAndRecord` by calling `tasks.trigger` directly.** Without `recordTriggerRun(handle.id, userId, taskId)`, the `/api/jobs/[runId]` proxy 404s forever. Worker runs in the dark, user billed, pipeline row dies on the bridge side. The ESLint guard banning direct `@trigger.dev/sdk` `tasks` imports outside `src/lib/dispatch/` exists for this. (Lab note 2026-05-11.)

**Don't store provider URLs (`*.openai.com`, `*.heygen.com`, `*.kie.ai`, etc.) in DB rows.** They expire in 1h. Every media-producing worker MUST call `persistMediaToStorage` and write the bucket path to `media_storage_paths`. Re-sign at read time. (Lab note 2026-04-24.)

---

## 11. Kickoff prompt

> Execute the Trigger.dev Worker Playbook at `<workspace>/templates/a-tier/TRIGGER-DEV-WORKER-PLAYBOOK.md` for a new task `<kind>`. Mirror the BKE implementation. Use the BKE codebase at `<reference-app>/src/` as the reference — when in doubt about a pattern, read the BKE file rather than re-deriving.
>
> Inputs:
> - `<kind>`: [slug, e.g. `render-podcast-clip`]
> - Provider chain: [list, e.g. "Whisper → Anthropic summarize → ffmpeg trim"]
> - Credit cost: [N credits, or "free / no charge"]
> - Max wall time: [seconds, drives `maxDuration`]
> - Native deps: [ffmpeg / ONNX / sharp / none]
>
> Pause after the three files land + `trigger.config.ts` updates for review. Don't wire the pipeline page until I confirm the worker runs clean in `npx trigger.dev dev`.

---

**Reference files in BKE:**
- `trigger.config.ts` — config + build extensions
- `src/trigger/` — every worker (render-text is the simplest reference)
- `src/trigger/audit-orphan-charges.ts` — hourly safety-net cron
- `src/lib/dispatch/trigger-and-record.ts` — mandatory dispatch wrapper
- `src/lib/autopilot/persist-worker-output.ts` — worker self-heal helper
- `src/lib/credits/refund.ts` — idempotent refund-on-failure
- [src/app/api/jobs/[runId]/route.ts](file:///<reference-app>/src/app/api/jobs/[runId]/route.ts) — generic status proxy
- `src/app/api/render/` — every dispatch route
- `src/lib/render/` — every browser bridge + `worker-poll.ts`
- `docs/worker-first-formats.md` — internal pattern doc (this playbook generalizes it)
- `docs/universal-trigger-migration-deployment.md` — original migration log
