.agents/skills/upstash-workflow/SKILL.md
Standard patterns for implementing Upstash Workflow + QStash async workflows in the LobeHub codebase.
Every workflow in LobeHub combines these three patterns. They exist because the platform constrains you in three ways: rate limits make blind fan-out dangerous, step limits cap a single workflow's size, and idempotency demands that retries don't double-process.
All workflows follow the same 3-layer architecture:
Layer 1: Entry Point (process-*)
āā Validates prerequisites
āā Calculates total items to process
āā Filters existing items
āā Supports dry-run mode (statistics only)
āā Triggers Layer 2 if work is needed
Layer 2: Pagination (paginate-*)
āā Handles cursor-based pagination
āā Implements fan-out for large batches
āā Recursively processes all pages
āā Triggers Layer 3 for each item
Layer 3: Single Task Execution (execute-* / generate-*)
āā Performs actual business logic for ONE item
Real examples in this codebase: welcome-placeholder, agent-welcome ā see references/examples.md.
Short-circuit Layer 1 before any side effects so callers can preview what would happen:
if (dryRun) {
return {
...result,
dryRun: true,
message: `[DryRun] Would process ${itemsNeedingProcessing.length} items`,
};
}
Use case: check how many items will be processed before committing.
Layer 2 splits oversized batches into chunks and recursively re-triggers itself with each chunk. This avoids hitting workflow step limits when one page contains too many items:
const CHUNK_SIZE = 20;
if (itemIds.length > CHUNK_SIZE) {
const chunks = chunk(itemIds, CHUNK_SIZE);
await Promise.all(
chunks.map((ids, idx) =>
context.run(`workflow:fanout:${idx + 1}/${chunks.length}`, () =>
WorkflowClass.triggerPaginateItems({ itemIds: ids }),
),
),
);
}
Defaults: PAGE_SIZE = 50 (items per page), CHUNK_SIZE = 20 (items per fan-out chunk).
Layer 3 always processes exactly one item per invocation. Parallelism comes from Layer 2 fanning out to many Layer 3 invocations, controlled by flowControl:
export const { POST } = serve<ExecutePayload>(
async (context) => {
const { itemId } = context.requestPayload ?? {};
if (!itemId) return { success: false, error: 'Missing itemId' };
const item = await context.run('workflow:get-item', () => getItem(itemId));
const result = await context.run('workflow:execute', () => processItem(item));
await context.run('workflow:save', () => saveResult(itemId, result));
return { success: true, itemId, result };
},
{
flowControl: { key: 'workflow.execute', parallelism: 10, ratePerSecond: 5 },
},
);
src/
āāā app/(backend)/api/workflows/
ā āāā {workflow-name}/
ā āāā process-{entities}/route.ts # Layer 1
ā āāā paginate-{entities}/route.ts # Layer 2
ā āāā execute-{entity}/route.ts # Layer 3
ā
āāā server/workflows/
āāā {workflowName}/
āāā index.ts # Workflow class
Pick the reference that matches what you're doing:
| You want to... | Read |
|---|---|
| Write the Workflow class + 3 routes from scratch | references/implementation.md |
| Tune flowControl, error handling, logging, testing | references/best-practices.md |
| See two real workflows end-to-end | references/examples.md |
| Deploy on lobehub-cloud (re-exports, cloud-only ops) | references/cloud.md |
# Required for all workflows
APP_URL=https://your-app.com # Base URL for workflow endpoints
QSTASH_TOKEN=qstash_xxx # QStash authentication token
# Optional (for custom QStash URL)
QSTASH_URL=https://custom-qstash.com
flowControl for each layercontext.run() step namesreferences/cloud.md if on lobehub-cloud)dryRun path + full path)