← All posts
·16 min read

Claude Code with Restate: Durable Execution Without the Drift

Claude CodeRestateDurable ExecutionWorkflows
Claude Code with Restate: Durable execution without the drift

Why Restate without CLAUDE.md ships handlers that double-charge on replay

Restate is a durable execution engine that runs your code as a sequence of replay-able steps. The runtime persists each step to a log, and on crash recovery it replays the function from the beginning, skipping the steps that already completed and resuming from where the failure happened. Done correctly, this gives you exactly-once semantics across crashes, network failures, and process restarts. Done incorrectly, it gives you the worst of both worlds: a function that charges the customer twice because the payment call was not wrapped as a side effect, and a function that loses state because the wrong key was used for the virtual object.

Claude Code, asked to "write a Restate handler that places an order," generates a handler that calls the payment provider directly without ctx.run, mutates a database without an idempotency key, accesses external state outside the durable context, and stores temporary computations in handler-local variables that get lost on replay. Each of those defaults is a way to break the durability guarantee.

This guide covers the CLAUDE.md configuration that locks Claude Code into Restate patterns that hold up: every side effect wrapped in ctx.run with a stable name, virtual objects keyed by stable business identifiers, idempotency keys on every invocation, awakeables for human-in-the-loop steps, the structural separation between durable handlers and pure business logic they delegate to, and the test harness that exercises replay paths. For the alternative durable-execution model with broader language support, Claude Code with Temporal covers Temporal. For the simpler model when you need event-driven background jobs without state machines, Claude Code with Inngest covers Inngest.

The Restate CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Restate integration it needs to declare: the SDK version, the deployment topology, the rules for what goes inside ctx.run, the virtual object key strategy, the idempotency-key convention, the awakeable patterns, the structural separation between handlers and business logic, and the hard rules that block the replay-unsafe code Claude generates by default.

# Restate rules

## Stack
- @restatedev/restate-sdk ^1.x (Node.js/TypeScript)
- restate-server 1.x (self-hosted) or Restate Cloud
- RESTATE_ADMIN_URL + RESTATE_INGRESS_URL env vars
- One service identity per repo: SERVICE_NAME = '<service>-<env>'

## Project structure
- src/services/                 , one file per Restate service (handler entry points)
- src/objects/                  , one file per virtual object (stateful, keyed)
- src/workflows/                , one file per workflow (multi-step orchestration)
- src/business/                 , pure business logic (NOT durable, called from ctx.run)
- src/clients/                  , external API clients (called from inside ctx.run)
- src/restate-app.ts            , Restate endpoint bundling services + objects + workflows
- tests/                        , unit tests for business logic + integration tests against restate-server

## Handler purity rules (MANDATORY)
- ALL external IO inside ctx.run('step-name', async () => { ... })
- ALL random values from ctx.rand or ctx.dateNow, NEVER Math.random or Date.now directly
- ALL sleeps via ctx.sleep, NEVER setTimeout
- ALL inter-service calls via ctx.serviceClient or ctx.objectClient
- NEVER mutate state outside ctx.run for virtual objects, use ctx.set/ctx.get

## Step naming (MANDATORY)
- Every ctx.run has a stable, unique-within-handler name as the first argument
- Names use kebab-case: 'charge-customer', 'create-shipping-label'
- NEVER omit the name (defaults to anonymous, hard to debug)
- NEVER reuse a name within the same handler (causes replay confusion)

## Virtual object keys (MANDATORY)
- Object key is a stable business identifier (orderId, accountId, tenantId)
- NEVER use UUIDs generated inside the handler as keys (changes on replay)
- NEVER use timestamps or random values as keys
- Document the key in src/objects/<name>.ts as a type-level constant

## Idempotency (MANDATORY)
- Every external invocation (HTTP, RPC) passes an Idempotency-Key header
- Key = `<handler-name>:<business-key>:<step-name>` for stability across replays
- Restate's internal step replay handles idempotency for ctx.run results
- External-API idempotency must be the caller's responsibility

## Awakeables (for human-in-the-loop)
- ctx.awakeable<T>() generates an ID, hand it to the external system, suspend until resolved
- Resolve via restate ingress endpoint with the ID and a payload
- ALWAYS pair awakeables with a timeout (ctx.sleep + Promise.race)
- NEVER leave an awakeable hanging without a timeout

## Test discipline
- Business logic in src/business/ is unit-testable without Restate
- Handlers in src/services/ are integration-tested against a real restate-server
- Replay tests: kill the runtime mid-execution, verify resume correctness

## Hard rules
- NEVER call fetch, axios, or any IO library outside ctx.run inside a handler
- NEVER use Math.random or Date.now inside a handler
- NEVER use setTimeout inside a handler
- NEVER call another service via a normal HTTP client, use ctx.serviceClient
- NEVER store state in handler-local variables that must survive a replay
- NEVER deploy a handler without idempotency-key headers on outbound API calls
- ALWAYS register handlers via endpoint.bind, NEVER hot-load
- ALWAYS log invocation-id + handler + step for every observable event

Six rules here prevent the majority of durable-execution incidents Claude generates without them.

The ctx.run rule is the foundation of Restate's durability model. Code inside ctx.run('name', fn) is persisted: the result of fn is logged after it completes, and on replay Restate returns the logged result instead of running fn again. Code outside ctx.run is replayed from scratch on every retry. A payment call outside ctx.run charges the customer once on the first run, then again on every replay. A payment call inside ctx.run charges once, persists the charge ID to the log, and returns the same charge ID on every replay. This is the entire reason Restate exists. Missing it loses the entire value proposition.

The step-name rule is the operational discipline that makes ctx.run actually work. Each step in a handler has a name that identifies it in the log. If two steps have the same name, the replay matches them incorrectly. If a step has no name, the log gets cluttered and debugging becomes impossible. The convention kebab-case-per-action keeps names readable and unique within a handler.

The virtual object key rule prevents the wrong-instance class of bug. Virtual objects in Restate are like actors: each unique key creates a new instance with its own persistent state. Handlers on the same key are serialized; handlers on different keys run in parallel. If the key is derived from the input correctly (e.g., orderId), state is consistent and concurrency works. If the key is a random UUID generated inside the handler, every replay creates a new instance, and the persistent state is unreachable.

The idempotency rule is the at-least-once safety net for external APIs. Restate handles internal step replay automatically: if ctx.run succeeds and the handler crashes before the next step, the next replay returns the logged result. But the external API call inside ctx.run might already have happened from the API's perspective even if Restate did not log the result (e.g., the response was lost between the API and the SDK). The fix is to include an idempotency key in the API call so the API recognizes a retry and returns the original result.

The delegation rule keeps handlers thin and testable. The handler is the durable wrapper. The business logic it calls is pure, unit-testable code in src/business/. The clients it uses to call external APIs are in src/clients/. This separation means most of the codebase can be tested without spinning up a Restate runtime, and the parts that need the runtime are the thin wrappers, which are easier to integration-test.

Install and basic service

Install the SDK:

pnpm add @restatedev/restate-sdk

Set the environment:

# .env.local
RESTATE_ADMIN_URL=http://localhost:9070
RESTATE_INGRESS_URL=http://localhost:8080
PORT=9080

Create a simple service:

// src/services/order-service.ts
import * as restate from '@restatedev/restate-sdk';
import { processCharge } from '../business/process-charge';
import { reserveInventory } from '../business/reserve-inventory';
import { paymentClient } from '../clients/payment';
import { inventoryClient } from '../clients/inventory';

export type PlaceOrderInput = {
  orderId: string;
  userId: string;
  amount: number;
  currency: string;
  items: string[];
};

export const orderService = restate.service({
  name: 'OrderService',
  handlers: {
    placeOrder: async (ctx: restate.Context, input: PlaceOrderInput) => {
      const chargeResult = await ctx.run('charge-customer', async () => {
        return paymentClient.charge({
          orderId: input.orderId,
          amount: input.amount,
          currency: input.currency,
          idempotencyKey: `OrderService:placeOrder:${input.orderId}:charge`,
        });
      });

      const reservationResult = await ctx.run('reserve-inventory', async () => {
        return inventoryClient.reserve({
          orderId: input.orderId,
          items: input.items,
          idempotencyKey: `OrderService:placeOrder:${input.orderId}:reserve`,
        });
      });

      await ctx.run('mark-order-confirmed', async () => {
        return markOrderConfirmed(input.orderId, {
          chargeId: chargeResult.chargeId,
          reservationId: reservationResult.reservationId,
        });
      });

      return {
        orderId: input.orderId,
        chargeId: chargeResult.chargeId,
        reservationId: reservationResult.reservationId,
      };
    },
  },
});

async function markOrderConfirmed(orderId: string, refs: { chargeId: string; reservationId: string }) {
  return { ok: true };
}

Three CLAUDE.md rules visible here. Every external call (payment, inventory, database update) is wrapped in ctx.run with a stable step name. Every external API call passes an idempotency key constructed from the handler name, the business key, and the step name, which is stable across replays. The business logic is delegated to functions in src/business/, which means a unit test can verify the order of operations without touching Restate.

The replay story for this handler: if the process crashes after charge-customer succeeds but before reserve-inventory starts, the next invocation replays the handler, returns the logged chargeResult, and starts running reserve-inventory for real. The customer is charged once. The inventory is reserved once. The order is confirmed once.

Virtual objects for per-entity state

A virtual object in Restate is a stateful actor keyed by a business identifier. Handlers on the same key are serialized; handlers on different keys run in parallel. State is persisted by Restate and survives crashes.

// src/objects/account.ts
import * as restate from '@restatedev/restate-sdk';

type AccountState = {
  balance: number;
  currency: string;
  pendingHolds: Record<string, number>;
};

export const accountObject = restate.object({
  name: 'Account',
  handlers: {
    debit: async (ctx: restate.ObjectContext, input: { amount: number; holdId: string }) => {
      const state = (await ctx.get<AccountState>('state')) ?? defaultState();

      if (state.balance < input.amount) {
        throw new restate.TerminalError('INSUFFICIENT_FUNDS', { errorCode: 422 });
      }

      const newState: AccountState = {
        ...state,
        balance: state.balance - input.amount,
        pendingHolds: { ...state.pendingHolds, [input.holdId]: input.amount },
      };

      ctx.set('state', newState);

      return { newBalance: newState.balance, holdId: input.holdId };
    },

    releaseHold: async (ctx: restate.ObjectContext, input: { holdId: string }) => {
      const state = (await ctx.get<AccountState>('state')) ?? defaultState();
      const heldAmount = state.pendingHolds[input.holdId];
      if (!heldAmount) {
        return { released: false };
      }

      const { [input.holdId]: _, ...remainingHolds } = state.pendingHolds;
      const newState: AccountState = {
        ...state,
        balance: state.balance + heldAmount,
        pendingHolds: remainingHolds,
      };

      ctx.set('state', newState);

      return { released: true, restored: heldAmount };
    },

    getBalance: async (ctx: restate.ObjectContext) => {
      const state = (await ctx.get<AccountState>('state')) ?? defaultState();
      return { balance: state.balance, currency: state.currency };
    },
  },
});

function defaultState(): AccountState {
  return { balance: 0, currency: 'USD', pendingHolds: {} };
}

Three things CLAUDE.md enforces here. The object name Account is its type identifier. The key (passed by the caller, not visible in the handler signature) is the account ID; all handlers on the same accountId run serially. The state is fetched via ctx.get and written via ctx.set, never via handler-local variables that would be lost on replay.

The TerminalError is the Restate way to fail without retry. A regular thrown error is retryable: Restate replays the handler. A TerminalError is non-retryable: the invocation completes with the error, the caller sees the failure, and no replay happens. The right classification matters: domain errors like INSUFFICIENT_FUNDS are terminal (no retry will help). Transient errors like NETWORK_TIMEOUT are non-terminal and should be thrown as regular errors so Restate retries.

Call the object from another service:

// inside another handler
const debitResult = await ctx.objectClient(accountObject, input.accountId).debit({
  amount: input.amount,
  holdId: input.orderId,
});

The ctx.objectClient call is itself a durable step: Restate logs the request and the response, so a replay returns the logged response instead of calling the object again. Combined with the object's serialized execution per key, this gives you per-account consistency without explicit locking.

Workflows for long-running orchestrations

A workflow is a service that orchestrates multiple steps, possibly across days or weeks, with awakeables for human input.

// src/workflows/order-fulfillment.ts
import * as restate from '@restatedev/restate-sdk';
import { paymentClient } from '../clients/payment';
import { shippingClient } from '../clients/shipping';

export const orderFulfillment = restate.workflow({
  name: 'OrderFulfillment',
  handlers: {
    run: async (ctx: restate.WorkflowContext, input: { orderId: string; userId: string; amount: number }) => {
      const chargeResult = await ctx.run('charge', async () => {
        return paymentClient.charge({
          orderId: input.orderId,
          amount: input.amount,
          currency: 'USD',
          idempotencyKey: `OrderFulfillment:run:${input.orderId}:charge`,
        });
      });

      const approval = ctx.awakeable<{ approved: boolean; approverId: string }>();
      await ctx.run('request-approval', async () => {
        return notifyApprover({
          orderId: input.orderId,
          awakeableId: approval.id,
        });
      });

      const approvalResult = await Promise.race([
        approval.promise,
        ctx.sleep(48 * 60 * 60 * 1000).then(() => ({ approved: false, approverId: 'TIMEOUT' })),
      ]);

      if (!approvalResult.approved) {
        await ctx.run('refund', async () => {
          return paymentClient.refund({
            chargeId: chargeResult.chargeId,
            idempotencyKey: `OrderFulfillment:run:${input.orderId}:refund`,
          });
        });
        return { status: 'rejected', reason: 'approval-denied-or-timeout' };
      }

      const shipmentResult = await ctx.run('ship', async () => {
        return shippingClient.createShipment({
          orderId: input.orderId,
          idempotencyKey: `OrderFulfillment:run:${input.orderId}:ship`,
        });
      });

      return {
        status: 'shipped',
        orderId: input.orderId,
        chargeId: chargeResult.chargeId,
        trackingId: shipmentResult.trackingId,
        approvedBy: approvalResult.approverId,
      };
    },
  },
});

async function notifyApprover(_: { orderId: string; awakeableId: string }) {
  return { sent: true };
}

Five things CLAUDE.md enforces here. The workflow charges the customer in a durable step. It creates an awakeable that suspends the workflow until an external system resolves it. The awakeable is paired with a 48-hour timeout via Promise.race against ctx.sleep, so the workflow does not hang forever. If the timeout wins or the approver denies, the workflow refunds the customer. The refund step has its own idempotency key, so a retry never double-refunds.

The runtime stores the entire state of the workflow on disk while it is suspended. The process can crash, the server can be redeployed, the workflow can sit idle for two days, and when the awakeable resolves the workflow resumes from exactly where it was. The customer-facing experience is identical to a process that ran continuously for 48 hours.

To resolve the awakeable from outside Restate:

curl -X POST $RESTATE_INGRESS_URL/restate/awakeables/$AWAKEABLE_ID/resolve \
  -H 'Content-Type: application/json' \
  -d '{"approved": true, "approverId": "user_123"}'

The awakeable ID is opaque and stable across the workflow's lifetime, so the external system can store it in its own database next to the approval request and resolve it whenever the approver clicks.

Bundling and serving the Restate endpoint

A Restate endpoint bundles services, objects, and workflows into a single HTTP server.

// src/restate-app.ts
import * as restate from '@restatedev/restate-sdk';
import { orderService } from './services/order-service';
import { accountObject } from './objects/account';
import { orderFulfillment } from './workflows/order-fulfillment';

restate
  .endpoint()
  .bind(orderService)
  .bind(accountObject)
  .bind(orderFulfillment)
  .listen(Number(process.env.PORT ?? 9080));

The endpoint listens on a port and serves the Restate protocol. The restate-server runtime connects to this endpoint, dispatches handler invocations to it, and stores the durability log. The application code does not know or care about the durability log; it just writes the handler.

Register the endpoint with the runtime:

restate -y deployments register http://localhost:9080

The runtime introspects the endpoint, discovers the services/objects/workflows, and routes incoming requests to them.

Test discipline for durable handlers

The business logic in src/business/ is unit-testable without any Restate runtime:

// tests/business/process-charge.test.ts
import { processCharge } from '../../src/business/process-charge';

test('rejects insufficient amount', () => {
  expect(() => processCharge({ amount: 0 })).toThrow('INVALID_AMOUNT');
});

The handlers in src/services/ are integration-tested against a real restate-server. The standard pattern is a docker-compose test fixture that spins up restate-server and a test-only build of the application, then drives invocations through the ingress endpoint and asserts on the result.

For replay tests, kill the application mid-handler, restart it, and assert that the handler resumes correctly and side effects fired exactly once. This is the test that catches ctx.run mistakes; without it, missing wrapping looks like working code in development and only surfaces under crash conditions in production.

Common Claude Code mistakes with Restate

Six patterns Claude generates incorrectly without CLAUDE.md constraints.

1. External IO outside ctx.run

Claude generates: const result = await fetch(url, { ... }) directly inside a handler.

Correct pattern: const result = await ctx.run('http-call-name', async () => fetch(url, { ... })).

2. Math.random or Date.now inside a handler

Claude generates: const requestId = crypto.randomUUID() or const now = Date.now() in handler scope.

Correct pattern: const requestId = ctx.rand.uuidv4() and const now = await ctx.dateNow(). Both are replay-safe.

3. setTimeout for delays

Claude generates: await new Promise(r => setTimeout(r, 1000)).

Correct pattern: await ctx.sleep(1000). Restate persists the wakeup time and survives crashes.

4. Calling another service via fetch

Claude generates: await fetch('http://other-service/endpoint', { ... }) for inter-service calls.

Correct pattern: await ctx.serviceClient(otherService).otherHandler(input). Restate routes through the runtime, logs the call/response, and replay-skips.

5. Missing idempotency keys on external API calls

Claude generates: paymentClient.charge({ amount, currency }) with no idempotency key.

Correct pattern: pass idempotencyKey: \:::`` to every external call.

6. Generic step names

Claude generates: ctx.run('step', fn) or ctx.run('', fn).

Correct pattern: ctx.run('charge-customer', fn) with a unique, descriptive name.

Add each pattern to CLAUDE.md with the corrected form.

Permission hooks for Restate CLI

The restate CLI has destructive operations: invocation cancel, deployment remove, state purge. Permission hooks gate them.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(restate invocations list*)",
      "Bash(restate invocations describe*)",
      "Bash(restate deployments list*)",
      "Bash(restate services list*)",
      "Bash(restate state get*)"
    ],
    "deny": [
      "Bash(restate invocations cancel*)",
      "Bash(restate invocations kill*)",
      "Bash(restate deployments remove*)",
      "Bash(restate services delete*)",
      "Bash(restate state clear*)"
    ]
  }
}

The allow list permits inspection. The deny list forces destructive operations into the explicit-confirmation path. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can inspect a stuck invocation but cannot kill it.

Observability

Every Restate deployment needs three sets of metrics: invocation counts (started, completed, failed, suspended), step durations (p50/p99 per handler, per step), and runtime resource usage (log storage, memory, CPU).

Invocation counts surface the workload. A growing suspended count means workflows are waiting on awakeables or sleeps, which is normal for human-in-the-loop. A growing failed count means handlers are throwing terminal errors, which usually points at a business-rule violation, not a system bug.

Step durations are the SLA signal. A p99 spike on a specific step usually points at a slow external call. Restate captures the step name in the log, so the trace gives you the answer immediately.

Runtime resource usage is the operational signal. Log storage grows with retention. Memory grows with the number of active virtual objects. CPU grows with invocation rate. For the observability stack that pulls these metrics, Claude Code with Datadog covers the integration.

Choosing Restate versus the alternatives

Restate is the right tool when you want durable execution with a code-first API and a small operational footprint. The runtime is a single binary, the SDK is thin, and the programming model is regular async code with a few well-defined primitives (ctx.run, ctx.set, ctx.awakeable).

Use Restate when: you want exactly-once execution semantics across crashes for handlers and workflows, you prefer a TypeScript/Go/Rust SDK over a separate workflow DSL, and you want virtual objects for per-entity state without setting up a database for it.

Use Temporal when: you need broad language support including Python and Java, you have an existing investment in workflow tooling, or you want the more mature operational model with a larger ecosystem.

Use Inngest when: your workload is event-driven background jobs rather than long-running workflows, you want a serverless-first deployment model, and you do not need virtual-object state.

Use a job queue like BullMQ or a message broker like Kafka when: your work is single-step, the consumer is the entire unit of work, and you do not need orchestration across multiple steps with state.

Building Restate code that survives a real crash

The Restate CLAUDE.md in this guide produces code where every external side effect is wrapped in ctx.run with a stable step name, every external API call carries an idempotency key constructed from the handler context, virtual objects are keyed by stable business identifiers, awakeables are paired with explicit timeouts, the business logic is delegated to pure unit-testable functions, and the test discipline includes a replay-test path that crashes the runtime mid-handler.

The underlying principle is the same as any durable-execution framework with Claude Code. Restate without a CLAUDE.md produces code that runs in the happy path and breaks the moment the runtime restarts mid-handler: payment calls double-charge, inventory reservations duplicate, awakeables hang forever. The CLAUDE.md is what makes the durability-aware patterns the default for Claude.

For the broader-language durable-execution alternative, Claude Code with Temporal covers Temporal. For event-driven background jobs without state machines, Claude Code with Inngest covers Inngest. For the messaging layer that often sits in front of durable workflows, Claude Code with Kafka covers streaming and Claude Code with RabbitMQ covers AMQP.

Get Claudify. Ship Restate handlers that survive crashes, replays, and retries.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame