Claude Code with Temporal: Durable Workflows Without Foot-Guns
Why Temporal without CLAUDE.md breaks on replay
Temporal is the durable execution platform that lets you write long-running workflows as ordinary code and trust the runtime to survive crashes, restarts, and deployments. A workflow that calls an activity, waits for a signal, sleeps for a week, and then continues is the kind of code Temporal makes feasible. The catch is that Temporal achieves this by recording every workflow event and replaying the workflow code from scratch when the worker resumes. Code that is not deterministic produces a different sequence of events on replay, and the workflow fails with a non-deterministic error.
The most common Claude defaults that break Temporal workflows: calling Date.now() or Math.random() inside a workflow (non-deterministic), making network calls or file I/O inside workflow code (the determinism boundary), forgetting to register activities with the worker, using imperative async patterns that change on retry (race conditions on replay), missing retry policies on activities that have legitimate failures, and using setTimeout instead of workflow.sleep() (the timer that Temporal can replay).
This guide covers the CLAUDE.md configuration that locks Claude Code into Temporal's correct model: deterministic workflow code, activities that wrap all side effects, signals and queries with typed interfaces, retry policies sized to the failure mode, and workflow versioning that survives deployment of incompatible changes. If you are building a Next.js application that triggers Temporal workflows from API routes, Claude Code with Next.js covers the request handling layer. For background job alternatives at smaller scale, Claude Code with Inngest covers a lighter-weight option.
The Temporal CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Temporal integration it needs to declare: the TS SDK versions, the workflow/activity boundary, the determinism rules, the retry policy defaults, and the hard rules that prevent the failure modes Claude generates by default.
# Temporal integration rules
## Stack
- @temporalio/client ^1.x, @temporalio/worker ^1.x, @temporalio/workflow ^1.x, @temporalio/activity ^1.x
- TypeScript 5.x strict (Temporal is TS-first in 2026)
- Node.js 20.x
- TEMPORAL_ADDRESS, TEMPORAL_NAMESPACE, TEMPORAL_TASK_QUEUE in .env
## Project structure (CRITICAL)
- src/workflows/ , workflow definitions (deterministic, no I/O)
- src/activities/ , activity implementations (all side effects live here)
- src/worker.ts , worker registration and run
- src/client.ts , Temporal client for starting workflows
- src/shared.ts , types and constants shared by workflows + activities
## Workflow rules (DETERMINISM)
Workflow code MUST be deterministic. Workflows can ONLY:
- Call activities through proxyActivities()
- Use workflow.sleep(ms) for delays (NEVER setTimeout)
- Use workflow.now() for time (NEVER Date.now())
- Use workflow.uuid4() for IDs (NEVER crypto.randomUUID())
- Receive signals and respond to queries
- Use deterministic logic (loops, conditionals, await on activities)
- Spawn child workflows
Workflows MUST NEVER:
- Make network calls directly
- Read/write files
- Use Math.random() or any non-seeded randomness
- Use Date.now() or new Date() for time
- Use setTimeout, setInterval, or any wall-clock timing
- Import Node modules that touch I/O (fs, http, fetch, axios)
- Mutate global state outside the workflow
## Activity rules
- All side effects live in activities
- Activities are called from workflows via proxyActivities()
- Activities are NOT deterministic, they can use any Node APIs
- Activities MUST be idempotent (Temporal may retry them)
- Activity types are declared in src/shared.ts and exported as a named export
- Activity implementations live in src/activities/ and match the type signature
## Retry policy (always declare)
proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
retry: {
initialInterval: '1s',
backoffCoefficient: 2,
maximumInterval: '60s',
maximumAttempts: 5,
nonRetryableErrorTypes: ['InvalidInputError', 'ValidationError'],
},
})
## Hard rules
- NEVER use Date.now(), new Date(), or setTimeout inside workflows
- NEVER call fetch, axios, fs, or any I/O directly inside workflows
- NEVER share state between workflow instances (each is isolated)
- NEVER skip retry policy declaration on proxyActivities()
- NEVER change a workflow's logic without using patched() / deprecatePatch()
- ALWAYS use workflow.uuid4() for IDs that need to be deterministic
- ALWAYS make activities idempotent (assume they may run twice)
- ALWAYS declare nonRetryableErrorTypes for permanent failures
Four rules here matter most.
The determinism rule is the foundational Temporal constraint. Every workflow event is recorded in history. When a worker resumes a workflow (after a crash, deployment, or task queue rebalance), it replays the workflow code from the start, expecting each event to occur in the same order. Code that uses wall-clock time, random numbers, or untracked side effects produces different events on replay and fails with NonDeterminismError. Claude does not know which APIs are workflow-safe by default; the CLAUDE.md rule enumerates them explicitly.
The activity boundary rule is the practical application of determinism. All side effects (HTTP calls, database writes, file I/O, third-party APIs) must live in activities. Activities run outside the deterministic replay loop, so they can do whatever Node.js can do. Workflows orchestrate, activities act. Claude often blurs this boundary by importing fetch directly into workflow files, which works in development but fails on the first retry.
The idempotency rule matches Temporal's at-least-once execution guarantee. An activity may run more than once due to retries, worker crashes between writes and acks, or scheduler decisions. If the activity is idempotent (the same input produces the same outcome regardless of repetition), retries are safe. If it is not (e.g., it increments a counter or sends an email without a dedupe key), retries cause double-billing or duplicate notifications. The CLAUDE.md rule makes idempotency a design constraint, not an afterthought.
The versioning rule is the deployment-time constraint. If you change workflow logic, in-flight workflow executions may replay the old logic with the new code and crash. Temporal provides patched() and deprecatePatch() helpers to introduce versioned branches that handle both old and new histories. Without this discipline, every workflow code change becomes a deployment risk.
Install and worker setup
Install the Temporal SDKs:
npm i @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity
npm i -D @temporalio/testing
Environment configuration:
# .env
TEMPORAL_ADDRESS=temporal-server.example.com:7233
TEMPORAL_NAMESPACE=production
TEMPORAL_TASK_QUEUE=claudify-tasks
Shared types and constants:
// src/shared.ts
export const TASK_QUEUE = process.env.TEMPORAL_TASK_QUEUE ?? 'claudify-tasks';
export interface ProcessOrderInput {
orderId: string;
customerId: string;
amountCents: number;
}
export interface ProcessOrderOutput {
paymentId: string;
shipmentId: string;
notificationId: string;
}
The activities (real side effects, not deterministic):
// src/activities/index.ts
import { Stripe } from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-09-30.basil' });
export async function chargeCustomer(customerId: string, amountCents: number, idempotencyKey: string) {
const intent = await stripe.paymentIntents.create(
{
customer: customerId,
amount: amountCents,
currency: 'gbp',
confirm: true,
},
{ idempotencyKey },
);
return intent.id;
}
export async function createShipment(orderId: string, idempotencyKey: string) {
// call shipping provider with idempotency key
return `ship_${idempotencyKey.slice(0, 8)}`;
}
export async function sendOrderConfirmation(customerId: string, orderId: string) {
// send email
return `email_${orderId}`;
}
export async function refundCharge(paymentId: string) {
await stripe.refunds.create({ payment_intent: paymentId });
}
The workflow (deterministic, orchestrates activities):
// src/workflows/process-order.ts
import { proxyActivities, workflow } from '@temporalio/workflow';
import type * as activities from '@/activities';
import type { ProcessOrderInput, ProcessOrderOutput } from '@/shared';
const { chargeCustomer, createShipment, sendOrderConfirmation, refundCharge } =
proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
retry: {
initialInterval: '1s',
backoffCoefficient: 2,
maximumInterval: '60s',
maximumAttempts: 5,
nonRetryableErrorTypes: ['InvalidInputError', 'ValidationError'],
},
});
export async function processOrder(input: ProcessOrderInput): Promise<ProcessOrderOutput> {
const paymentIdempotencyKey = workflow.uuid4();
const shipmentIdempotencyKey = workflow.uuid4();
const paymentId = await chargeCustomer(input.customerId, input.amountCents, paymentIdempotencyKey);
let shipmentId: string;
try {
shipmentId = await createShipment(input.orderId, shipmentIdempotencyKey);
} catch (err) {
// Compensating action: refund the charge if shipment fails permanently
await refundCharge(paymentId);
throw err;
}
const notificationId = await sendOrderConfirmation(input.customerId, input.orderId);
return { paymentId, shipmentId, notificationId };
}
Notice the idempotency keys generated with workflow.uuid4(). On a retry of the workflow, the same UUID is replayed (Temporal records the UUID generation in history), so the activity's idempotency key remains stable across retries. Using crypto.randomUUID() instead would generate a fresh key on each retry, which would cause Stripe to create a fresh payment intent every time.
The worker registers activities and workflows:
// src/worker.ts
import { Worker, NativeConnection } from '@temporalio/worker';
import * as activities from '@/activities';
import { TASK_QUEUE } from '@/shared';
async function run() {
const connection = await NativeConnection.connect({
address: process.env.TEMPORAL_ADDRESS,
});
const worker = await Worker.create({
connection,
namespace: process.env.TEMPORAL_NAMESPACE,
taskQueue: TASK_QUEUE,
workflowsPath: require.resolve('./workflows'),
activities,
});
console.log(`[Worker] Starting on task queue ${TASK_QUEUE}`);
await worker.run();
}
run().catch(err => {
console.error(err);
process.exit(1);
});
The client triggers workflows:
// src/client.ts
import { Connection, Client } from '@temporalio/client';
import { processOrder } from '@/workflows/process-order';
import { TASK_QUEUE } from '@/shared';
export async function startOrderWorkflow(orderId: string, customerId: string, amountCents: number) {
const connection = await Connection.connect({ address: process.env.TEMPORAL_ADDRESS });
const client = new Client({ connection, namespace: process.env.TEMPORAL_NAMESPACE });
const handle = await client.workflow.start(processOrder, {
taskQueue: TASK_QUEUE,
workflowId: `process-order-${orderId}`,
args: [{ orderId, customerId, amountCents }],
});
return handle.workflowId;
}
The workflowId is your deduplication key. Starting a workflow with the same workflowId twice (without workflowIdReusePolicy overrides) returns the existing handle, not a new execution. This is how you make HTTP request handlers idempotent: derive the workflowId from the request payload, and Temporal handles the rest.
Signals and queries
Workflows can receive signals (fire-and-forget messages that update workflow state) and respond to queries (synchronous reads of workflow state). Both are typed through interfaces.
// src/workflows/subscription.ts
import { defineSignal, defineQuery, proxyActivities, setHandler, workflow } from '@temporalio/workflow';
import type * as activities from '@/activities';
const { chargeSubscription } = proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds',
});
export const cancelSignal = defineSignal('cancel');
export const pauseSignal = defineSignal<[number]>('pause');
export const statusQuery = defineQuery<{ status: string; nextChargeAt: string }>('status');
interface SubscriptionInput {
customerId: string;
amountCents: number;
}
export async function subscription(input: SubscriptionInput) {
let cancelled = false;
let pausedUntil = 0;
let nextChargeAt = workflow.now();
setHandler(cancelSignal, () => {
cancelled = true;
});
setHandler(pauseSignal, (untilMs: number) => {
pausedUntil = untilMs;
});
setHandler(statusQuery, () => ({
status: cancelled ? 'cancelled' : pausedUntil > workflow.now() ? 'paused' : 'active',
nextChargeAt: new Date(nextChargeAt).toISOString(),
}));
while (!cancelled) {
if (pausedUntil > workflow.now()) {
await workflow.sleep(pausedUntil - workflow.now());
continue;
}
await chargeSubscription(input.customerId, input.amountCents);
nextChargeAt = workflow.now() + 30 * 24 * 60 * 60 * 1000; // 30 days
await workflow.sleep(nextChargeAt - workflow.now());
}
}
The client side sending a signal:
import { Client } from '@temporalio/client';
import { cancelSignal } from '@/workflows/subscription';
export async function cancelSubscription(workflowId: string) {
const client = new Client({ namespace: process.env.TEMPORAL_NAMESPACE });
const handle = client.workflow.getHandle(workflowId);
await handle.signal(cancelSignal);
}
Add a signals and queries section to CLAUDE.md:
## Signals and queries
- Define signal: defineSignal<[ArgType]>('signal-name')
- Define query: defineQuery<ReturnType>('query-name')
- Register handlers with setHandler() at the top of the workflow
- Signals are async fire-and-forget, do NOT return a value
- Queries are synchronous reads, MUST be deterministic, no side effects
- Use signals for external state changes (cancel, pause, update)
- Use queries for read-only state inspection (status, progress)
- Signal handlers run as part of workflow execution, must be deterministic
- Query handlers run during replay, must produce the same result for the same state
Get Claudify. The bundled Temporal CLAUDE.md ships with the workflow boundary rules, the idempotency activity helper, and signal/query type-safety patterns pre-configured.
Workflow versioning with patched()
When you change workflow logic, in-flight executions may resume with new code. If the new code's events do not match the recorded history, the workflow fails with NonDeterminismError. Temporal provides patched() and deprecatePatch() to introduce versioned branches.
import { patched, proxyActivities, workflow } from '@temporalio/workflow';
import type * as activities from '@/activities';
const { chargeCustomer, chargeCustomerV2 } = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
});
export async function processOrder(input: ProcessOrderInput) {
if (patched('use-v2-charge-api')) {
await chargeCustomerV2(input.customerId, input.amountCents);
} else {
await chargeCustomer(input.customerId, input.amountCents);
}
}
How this works on replay:
- Workflows started before the patched call exists:
patched()returnsfalse, replays the old branch - Workflows started after the patched call exists:
patched()returnstrue, replays the new branch - The patch ID is recorded in history, ensuring deterministic replay
After all in-flight workflows have completed under the old branch, replace patched() with deprecatePatch() to retain the version marker without forcing future workflows down the old code path. Eventually, the deprecated patch can be removed.
Add a versioning section to CLAUDE.md:
## Workflow versioning
- ANY change to workflow logic for in-flight workflows MUST use patched()
- Adding new activities or changing activity signatures is safe (activities can change freely)
- Changing the order, conditional logic, or sleep durations REQUIRES patched()
- Use a descriptive patch ID: 'use-v2-charge-api', 'add-retry-loop'
- After all v1 workflows complete: replace patched() with deprecatePatch()
- Eventually delete the deprecated branch entirely (next deployment after deprecatePatch)
- NEVER remove patched() without deprecatePatch() first, breaks in-flight workflows
Retry policies sized to failure modes
The default retry policy retries every error indefinitely with exponential backoff. This is rarely correct. A 400 Bad Request will be retried forever, blocking the workflow. A 500 Internal Server Error may resolve in seconds with one retry. Tuning the retry policy to the activity's failure modes is the difference between a workflow that recovers and a workflow that stalls.
| Failure mode | Recommended retry |
|---|---|
| Transient network error | 5 attempts, 1s -> 16s exponential |
| Rate limit (429) | 10 attempts, 5s -> 5 min exponential |
| Provider downtime (5xx) | 20 attempts, 10s -> 30 min exponential |
| Invalid input (4xx) | 0 attempts, non-retryable |
| Authentication failure | 1 attempt, non-retryable |
| Idempotent write | Up to 50 attempts (idempotency makes retries free) |
Implementing per-activity retry policies:
const transientActivities = proxyActivities<typeof activities>({
startToCloseTimeout: '30s',
retry: {
initialInterval: '1s',
maximumInterval: '16s',
backoffCoefficient: 2,
maximumAttempts: 5,
nonRetryableErrorTypes: ['InvalidInputError'],
},
});
const longRunningActivities = proxyActivities<typeof activities>({
startToCloseTimeout: '5 minutes',
retry: {
initialInterval: '10s',
maximumInterval: '5 minutes',
backoffCoefficient: 2,
maximumAttempts: 20,
nonRetryableErrorTypes: ['ValidationError', 'PermanentFailureError'],
},
});
Throwing typed errors from activities to drive the non-retryable list:
// src/activities/errors.ts
export class InvalidInputError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidInputError';
}
}
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
// src/activities/charge.ts
import { InvalidInputError } from './errors';
export async function chargeCustomer(customerId: string, amountCents: number, idempotencyKey: string) {
if (amountCents <= 0) {
throw new InvalidInputError('Amount must be positive');
}
// ... actual charge
}
Idempotent activities
Activities are at-least-once. They will run again on retry. Designing them to be idempotent (safe to run multiple times with the same input) is the single most important practice for production Temporal workflows.
Patterns for idempotency:
| Operation | Idempotency strategy |
|---|---|
| Stripe payment | Pass idempotencyKey to the SDK |
| Database insert | UPSERT or check-then-insert with unique constraint |
| Email send | Dedupe by message ID in your DB |
| External API write | Use the provider's idempotency header |
| File write | Write to a stable path, overwrites are idempotent |
| Counter increment | Check if already applied for this operation ID |
Generate the idempotency key in the workflow with workflow.uuid4() and pass it to the activity, so retries replay the same key:
const paymentKey = workflow.uuid4();
await chargeCustomer(customerId, amount, paymentKey);
Add idempotency rules to CLAUDE.md:
## Activity idempotency
- ALL activities MUST be idempotent
- Generate idempotency keys with workflow.uuid4() inside the workflow
- Pass the key to the activity, the activity uses it for provider deduplication
- For DB writes: UPSERT or unique-constraint + ON CONFLICT DO NOTHING
- For external APIs: pass idempotencyKey header (Stripe, Twilio, Resend support this)
- For email: dedupe on message_id in your DB before sending
- ALWAYS log the idempotency key in activity logs for debugging
Common Claude Code mistakes with Temporal
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Date.now() in workflow code
Claude generates: const now = Date.now(); inside a workflow.
Correct pattern: const now = workflow.now(); for replay-safe time.
2. setTimeout for delays
Claude generates: await new Promise(resolve => setTimeout(resolve, 5000));.
Correct pattern: await workflow.sleep('5s'); for replay-safe timers.
3. fetch inside a workflow
Claude generates: const response = await fetch('https://api.example.com/...'); in a workflow file.
Correct pattern: move the fetch into an activity, call the activity via proxyActivities().
4. No retry policy
Claude generates: proxyActivities<typeof activities>({ startToCloseTimeout: '1m' }) with no retry config.
Correct pattern: explicit retry policy with maximumAttempts and nonRetryableErrorTypes.
5. crypto.randomUUID() for keys
Claude generates: const key = crypto.randomUUID(); in a workflow.
Correct pattern: const key = workflow.uuid4(); (recorded in history, stable on retry).
6. Logic change without patched()
Claude generates: a direct change to workflow logic, expecting it to apply on next deployment.
Correct pattern: wrap the change in patched('new-behavior') until all v1 workflows complete.
Add these as before/after pairs in CLAUDE.md.
Permission hooks for Temporal scripts
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(node scripts/list-workflows.js*)",
"Bash(node scripts/describe-workflow.js*)",
"Bash(node scripts/query-workflow.js*)"
],
"deny": [
"Bash(node scripts/terminate-workflow.js*)",
"Bash(node scripts/cancel-all.js*)",
"Bash(node scripts/reset-workflow.js*)"
]
}
}
Terminate, cancel, and reset operations destroy workflow state. The deny list forces Claude to surface those operations as confirmation prompts. For broader hook patterns, Claude Code hooks covers the full configuration model.
Building Temporal workflows that survive replay
The Temporal CLAUDE.md in this guide produces workflows where time, randomness, and side effects all flow through replay-safe APIs, activities are idempotent, retry policies are sized to the failure mode, signals and queries are typed, and versioning uses patched() to keep in-flight workflows working through deployments.
The underlying principle is that Temporal is a constraint-based system, and the constraints are not optional. Determinism is what enables the durability guarantee. Idempotency is what enables the at-least-once execution model. Without CLAUDE.md, Claude generates code that satisfies the type checker and runs in development but fails the first time a worker restarts or an activity retries.
For comparison with simpler durable execution alternatives, Claude Code with Inngest covers a fully managed background job platform that handles many of the same use cases with a less strict programming model. For payment workflows specifically, pair Temporal with Claude Code with Stripe. Get Claudify. The bundled Temporal template ships with the workflow boundary rules, idempotent activity helpers, and the patched() versioning pattern pre-configured.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify