Ploy
Ploy

Workflows

Build durable multi-step processes in your Next.js app with Ploy workflows.

Workflows

Build long-running, multi-step processes that survive failures with automatic retries and state persistence.

Configuration

Add a workflow binding in your ploy.yaml:

ploy.yaml
kind: dynamic
build: pnpm build
out: dist
workflow:
  DATA_FLOW: data_processing

The key (DATA_FLOW) is the binding name. The value (data_processing) must match the workflow function name in your code.

You can use the expanded form to configure retries per workflow:

ploy.yaml
workflow:
  DATA_FLOW:
    name: data_processing
    retries: 5 # Retry failed steps up to 5 times (default: 3)

Generate types:

pnpm ploy types

Triggering Workflows

Trigger workflows from API routes or Server Actions:

app/api/workflow/route.ts
import { NextResponse } from "next/server";
import { getPloyContext } from "@meetploy/nextjs";

export async function POST(request: Request) {
	const { env } = getPloyContext();
	const body = await request.json();

	const { executionId } = await env.DATA_FLOW.trigger({
		action: body.action,
		value: body.value,
	});

	return NextResponse.json({ success: true, executionId });
}

Get Execution Status

export async function GET(request: Request) {
	const { env } = getPloyContext();
	const url = new URL(request.url);
	const executionId = url.searchParams.get("executionId");

	const execution = await env.DATA_FLOW.getExecution(executionId);

	return NextResponse.json({
		id: execution.id,
		status: execution.status, // "running" | "completed" | "failed"
		output: execution.output,
	});
}

Cancel Execution

await env.DATA_FLOW.cancel(executionId);

Workflow Definition

Define workflows in your ploy.ts file:

app/ploy.ts
interface WorkflowInput {
	action: string;
	value: number;
}

interface WorkflowOutput {
	action: string;
	originalValue: number;
	processedValue: number;
}

export default {
	workflows: {
		data_processing: {
			handler: async ({
				input,
				env,
				step,
			}: WorkflowContext<PloyEnv, WorkflowInput>): Promise<WorkflowOutput> => {
				// Step 1: Validate input
				const validation = await step.run("validate", async () => {
					if (!input.action) {
						throw new Error("Missing action");
					}
					return { valid: true, action: input.action };
				});

				// Step 2: Process the value
				const processed = await step.run("process", async () => {
					let result: number;
					switch (input.action) {
						case "double":
							result = input.value * 2;
							break;
						case "square":
							result = input.value * input.value;
							break;
						default:
							result = input.value;
					}
					return { processedValue: result };
				});

				// Step 3: Store result
				await step.run("store", async () => {
					await env.DB.prepare(
						"INSERT INTO results (action, input, output) VALUES (?, ?, ?)",
					)
						.bind(input.action, input.value, processed.processedValue)
						.run();
				});

				return {
					action: input.action,
					originalValue: input.value,
					processedValue: processed.processedValue,
				};
			},
		},
	},
} satisfies Ploy;

Inputs and Outputs

There are two levels of persisted workflow data:

  • Execution input: the payload passed to env.DATA_FLOW.trigger(...)
  • Execution output: the final value returned by the workflow handler

For deeper debugging, each step can also persist its own snapshots:

  • Step input: optional metadata passed with step.run(..., { input })
  • Step output: the value returned from the step callback
const processed = await step.run(
	"process",
	async () => {
		return { processedValue: input.value * 2 };
	},
	{
		input: {
			action: input.action,
			value: input.value,
		},
	},
);

This is especially useful when a workflow transforms data between steps and you want the dashboard to show what each step actually received.

Step API

step.run

Execute a named step. Results are persisted, so re-runs skip completed steps:

const result = await step.run("step-name", async () => {
	return { data: "value" };
});

step.sleep

Pause execution for a duration (in milliseconds):

await step.sleep(5000); // Wait 5 seconds

Generated worker runtimes use the configured callback retry default (normally three). The Next.js development route defaults to zero; set per-step { retries: 3 } explicitly when testing callback retries there. Use { retries: false } (or { retries: 0 }) for non-idempotent effects.

Callback retries and completion acknowledgements are separate. Once a callback succeeds, transient completion failures retry only the acknowledgement, at most twice more with the same serialized output. Every acknowledgement checks the HTTP status and success response. Exhausted or rejected acknowledgements surface an infrastructure error without rerunning successful callbacks, invoking onError, or marking the workflow failed. Workflow completion follows the same rule.

This does not guarantee exactly-once effects across a crash: an external operation may succeed before its completion is persisted. Even with retries: false, a later replay can execute an uncommitted step again. Use provider idempotency keys or reconcile uncertain effects. Unique step names identify persisted results, not atomic external side effects. Reported acknowledgement uncertainty holds the running execution out of automatic failure/recovery until investigation or explicit cancellation. An unreported host failure or a lost entire execution response can still trigger best-effort recovery and replay uncommitted work.

With suspendable dispatch, sleep persists a wake-up deadline and returns control to the broker/emulator until that deadline. Replay reuses the persisted deadline and skips completed elapsed sleeps. Legacy non-suspendable dispatch still uses a process-local timer and is not host-failure durable.

Error Handling

Each workflow definition supports an optional onError callback that runs when the handler throws. Use it for cleanup, alerting, or releasing locks:

workflows: {
	data_processing: {
		handler: async ({ input, env, step }) => {
			// ... workflow steps
		},
		onError: async (error, { env }) => {
			console.error("Workflow failed:", error.message);
			// Release locks, send alerts, clean up resources
		},
	},
},

The onError hook runs before the workflow is marked as failed. If onError itself throws, the error is ignored and the workflow still fails normally.

Locally, workflow steps are dispatched to your app's /ploy-handler route, which initPloyForDev() creates for you. See Background Handlers.

Local Development

During development, the local Ploy dashboard at http://localhost:4000 lets you:

  • View workflow executions
  • Inspect step results
  • Monitor execution status
Workflow executions list in the local Ploy dashboard

Open an execution to inspect execution input/output, step-level input/output, retry attempts, and timing details.

Workflow execution detail view showing execution and step inputs and outputs

Next Steps

How is this guide?

Last updated on