Backend & APIs No. 098Editor's pick

Temporal Workflows skill for Claude Code

Durable processes that survive deploys and replays. Claude keeps Temporal workflow code deterministic and pushes every side effect into idempotent activities with explicit timeouts, heartbeats and non-retryable business errors. It models sagas with compensations, uses signals, queries and updates correctly, continues long loops as new, and treats every workflow code change as a versioning decision proven by replaying stored production histories in CI.

  • nametemporal-workflows
  • size261 lines · ≈2,850 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs and reviews durable executions on Temporal - splitting deterministic workflow code from side-effecting activities, timeouts and retry policies, heartbeats, idempotent activities, signals, queries and updates, sagas with compensation, continue-as-new for long histories, safe code changes with patching and Worker Versioning, replay tests against real histories, and time-skipping tests, in the TypeScript, Python and Go SDKs. Use when writing Temporal workflows or activities, hitting a non-determinism error, deploying workflow code changes, or deciding whether Temporal fits.

Try asking

  • Model our order fulfilment as a Temporal workflow with refunds on failure
  • We got a nondeterminism error after deploying. Fix it without breaking running workflows
  • Review these Temporal activities for missing timeouts and retry problems

Use it when

  • Writing or reviewing Temporal workflows and activities
  • A deploy caused nondeterminism errors in running workflows
  • Adding signals, queries, updates or human approval steps
  • Deciding between Temporal and a plain job queue

Skip it when

How to check the result

Time-skipping tests cover the happy and compensation paths, replay tests against stored production histories pass with the new code, and staging runs show no workflow task failures.

The common mistake it prevents

Reordering or adding activity calls in a workflow that has executions in flight without a patch or a pinned deployment version, which fails every running execution on replay.

~/.claude/skills/temporal-workflows/SKILL.md

--- name: temporal-workflows description: Designs and reviews durable executions on Temporal - splitting deterministic workflow code from side-effecting activities, timeouts and… license: MIT ---

Done well means: workflow code is deterministic and only orchestrates, every side effect is an idempotent activity with an explicit timeout, every change to workflow logic ships behind a patch or a pinned Worker Deployment Version, and a replay test against real production histories passes before deploy.

Fit check

SituationTemporal?
Multi-step process with retries, waits of minutes to months, human approval, compensationYes
Order fulfilment, payments orchestration, provisioning, onboarding, data pipelines with checkpointsYes
Fire-and-forget job that runs in seconds and can simply retryNo; a job queue is simpler (background-jobs)
Sub-millisecond request pathNo; workflow start adds a round trip to the cluster
Pure event fan-out between servicesNo; use a broker

Workflow

- [ ] 1. Inventory SDK, server or Cloud, task queues, existing versioning approach
- [ ] 2. Draw the process: steps, waits, external messages, compensations
- [ ] 3. Write activities first: idempotent, with timeouts and non-retryable errors
- [ ] 4. Write the workflow: deterministic orchestration only
- [ ] 5. Add signals, queries, or updates for external interaction
- [ ] 6. Test with the time-skipping environment; add replay tests
- [ ] 7. Plan the deploy: patch or Worker Versioning; confirm with replay

1. Inventory

npm ls @temporalio/workflow @temporalio/worker 2>/dev/null; pip show temporalio 2>/dev/null | head -2
grep -rn "patched\|GetVersion\|deprecatePatch\|deprecate_patch" src/ | head
temporal workflow list --query 'ExecutionStatus="Running"' --limit 20

Long-running workflows already in flight constrain every change you make; count them per workflow type before editing that type.

2. Determinism rules

Workflow code is replayed from event history on any worker at any time. It must produce the same commands in the same order on every replay.

Forbidden in workflow codeUse instead
Network, database, file IO, logging to external sinksAn activity
Date.now(), time.time(), time.Now()Workflow time (TS sandbox makes Date deterministic; Python workflow.now(); Go workflow.Now(ctx))
Random numbers, uuid4() from the stdlibSDK deterministic helpers (TS uuid4() from @temporalio/workflow, Python workflow.uuid4() / workflow.random(), Go workflow.SideEffect)
Threads, goroutines, native select, asyncio.create_task on other loopsworkflow.Go, workflow.NewSelector, Promise.all, Python asyncio.gather inside the workflow
setTimeout / sleep from the languagesleep() / workflow.sleep() / workflow.Sleep() (durable timers)
Iterating Go maps, reading env vars or global mutable stateSorted keys; pass config as workflow input
Changing the order or type of activity calls in a running workflowA patch or a new Worker Deployment Version

3. Activities

// activities.ts — normal Node code; may do IO
import { Context, ApplicationFailure } from "@temporalio/activity";

export async function chargeCard(input: { orderId: string; amountCents: number }) {
  const idempotencyKey = `charge-${input.orderId}`;           // stable across retries
  const res = await payments.charge({ ...input, idempotencyKey });
  if (res.declined) {
    throw ApplicationFailure.nonRetryable("card declined", "CardDeclined");
  }
  return { chargeId: res.id };
}

export async function exportRows(input: { jobId: string; fromOffset: number }) {
  const ctx = Context.current();
  let offset = ctx.info.heartbeatDetails?.offset ?? input.fromOffset;  // resume after retry
  while (offset < total) {
    await writeBatch(offset);
    offset += 1000;
    ctx.heartbeat({ offset });                                  // also delivers cancellation
  }
}
  • Activities are at-least-once. Derive idempotency keys from workflow ID plus a step name and pass them to downstream APIs.
  • Always set startToCloseTimeout (max time for one attempt). Add heartbeatTimeout for anything longer than about a minute and heartbeat well inside it.
  • The default retry policy retries forever with exponential backoff (1 s initial, coefficient 2, capped at 100 x initial). Cap maximumAttempts or set a scheduleToCloseTimeout for calls where unbounded retry makes no sense, and list business errors in nonRetryableErrorTypes.
  • Keep payloads small (each payload is limited to about 2 MB). Pass IDs or object-store keys, not files.

4. Workflow

// workflows.ts — deterministic orchestration only
import { proxyActivities, defineSignal, defineQuery, defineUpdate, setHandler, condition, sleep, log } from "@temporalio/workflow";
import type * as acts from "./activities";

const { reserveStock, releaseStock, chargeCard, refundCharge, shipOrder } = proxyActivities<typeof acts>({
  startToCloseTimeout: "30 seconds",
  retry: { maximumAttempts: 10, nonRetryableErrorTypes: ["CardDeclined", "OutOfStock"] },
});

export const cancelSignal = defineSignal("cancel");
export const statusQuery = defineQuery<string>("status");
export const addressUpdate = defineUpdate<string, [string]>("changeAddress");

export async function orderWorkflow(order: Order): Promise<string> {
  let status = "started";
  let cancelled = false;
  let address = order.address;
  setHandler(cancelSignal, () => { cancelled = true; });
  setHandler(statusQuery, () => status);
  setHandler(addressUpdate, (a) => { address = a; return address; }, {
    validator: (a) => { if (status === "shipped") throw new Error("already shipped"); },
  });

  const compensations: Array<() => Promise<unknown>> = [];
  try {
    await reserveStock(order.id, order.items);
    compensations.unshift(() => releaseStock(order.id));

    const { chargeId } = await chargeCard({ orderId: order.id, amountCents: order.total });
    compensations.unshift(() => refundCharge(chargeId));
    status = "paid";

    // wait up to 1 hour for a cancel before shipping (durable timer)
    if (await condition(() => cancelled, "1 hour")) throw new Error("cancelled by customer");

    await shipOrder(order.id, address);
    status = "shipped";
    return status;
  } catch (err) {
    log.warn("compensating", { orderId: order.id });
    for (const undo of compensations) await undo();   // compensations are activities too
    status = "compensated";
    throw err;
  }
}

Start it with a business ID so duplicates are rejected:

await client.workflow.start(orderWorkflow, {
  taskQueue: "orders",
  workflowId: `order-${order.id}`,          // dedupe: one workflow per order
  args: [order],
});

Message types:

NeedUseNotes
Fire-and-forget input that changes stateSignalAsync, durable, no return value
Read current stateQueryMust not mutate state or call activities
Request with validation and a resultUpdateValidator rejects before history is written
Start if absent, then sendSignal-with-start / update-with-startRemoves the start/send race

5. Long-running workflows

  • Event history has hard limits (51,200 events or 50 MB); the server warns from about 10,240 events. Loops that run forever (subscriptions, polling, entity workflows) call continue-as-new when the SDK reports it is suggested (TS workflowInfo().continueAsNewSuggested, Python workflow.info().is_continue_as_new_suggested()), carrying state as input.
  • Drain signal handlers before continuing as new, or messages received in that task are lost; wait with condition(allHandlersFinished) in TS or the equivalent.
  • Child workflows partition very large fan-outs (for example 1,000 children of 1,000 activities each) and give each part its own history.

6. Changing workflow code safely

A change that alters which commands a workflow emits breaks replay of running executions (NondeterminismError / “nondeterministic workflow”). Pick one:

OptionUse when
No versioning neededChange only affects activity implementations, or no executions of this type are running
Patching (patched("id"), workflow.patched, workflow.GetVersion)Small logic changes; old and new paths coexist in code until old executions finish, then deprecatePatch, then remove
Worker Versioning (GA; Pinned or Auto-Upgrade per workflow type)Frequent deploys; Pinned workflows finish on the build they started on, so no patches needed for short workflows; requires running old and new worker versions side by side (rainbow deploys)
New workflow type nameLarge rewrites; route new starts to the new type and let the old drain
import { patched, deprecatePatch } from "@temporalio/workflow";
if (patched("fraud-check-before-charge")) {
  await fraudCheck(order.id);
}
// later, once no pre-patch executions remain: deprecatePatch("fraud-check-before-charge")

Auto-Upgrade workflows still need patching for incompatible changes.

7. Tests

import { TestWorkflowEnvironment } from "@temporalio/testing";
import { Worker } from "@temporalio/worker";
import { ApplicationFailure } from "@temporalio/common";

const env = await TestWorkflowEnvironment.createTimeSkipping();
const worker = await Worker.create({
  connection: env.nativeConnection,
  taskQueue: "test",
  workflowsPath: require.resolve("./workflows"),
  activities: { ...fakeActivities, chargeCard: async () => { throw ApplicationFailure.nonRetryable("x", "CardDeclined"); } },
});
await worker.runUntil(async () => {
  await expect(env.client.workflow.execute(orderWorkflow, { taskQueue: "test", workflowId: "t1", args: [order] }))
    .rejects.toThrow();
  // assert releaseStock fake was called
});
await env.teardown();

Replay tests catch non-determinism before deploy:

temporal workflow show --workflow-id order-123 --output json > histories/order-123.json
await Worker.runReplayHistory({ workflowsPath: require.resolve("./workflows") }, history);

Keep a few histories per workflow type (happy path, compensation, long wait) in the repo and replay them in CI on every workflow change.

For Python and Go equivalents of every snippet above (activities, timers, signals, updates, patching, continue-as-new, test environments, replayers), read references/sdk-cheatsheet.md when the project is not TypeScript.

Gotchas

  • Importing activity implementations (not just types) into workflow files pulls Node modules into the workflow sandbox and fails the bundle.
  • Python workflow files run in a sandbox; import third-party modules through workflow.unsafe.imports_passed_through().
  • A workflow that catches every error and retries in a loop builds history fast; let the activity retry policy do retries.
  • temporal workflow reset and terminate change running business processes; run them only with explicit user confirmation and a reason recorded.
  • Sensitive data lands in event history in plain text unless a payload codec encrypts it.

Verification

  1. Unit and time-skipping tests pass, including the compensation path.
  2. Replay tests against stored production histories pass with the new code.
  3. temporal workflow describe --workflow-id <id> on a staging run shows expected pending activities and no task failures.
  4. After deploy, watch for workflow task failures with non-determinism errors; on the first one, roll back the worker and add a patch.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/sdk-cheatsheet.md 153 lines · 7.0 KB
# Temporal SDK cheatsheet: TypeScript, Python, Go

Equivalent constructs across the three most used SDKs. Names reflect the TypeScript SDK
1.x, Python SDK 1.x (`temporalio`), and Go SDK (`go.temporal.io/sdk`). When a signature
detail matters, confirm it against the SDK reference for the installed version.

## Calling activities from a workflow

| | Code |
|---|---|
| TS | `const { charge } = proxyActivities<typeof acts>({ startToCloseTimeout: "30s", retry: { maximumAttempts: 5 } }); await charge(input);` |
| Python | `await workflow.execute_activity(charge, input, start_to_close_timeout=timedelta(seconds=30), retry_policy=RetryPolicy(maximum_attempts=5))` |
| Go | `ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{StartToCloseTimeout: 30 * time.Second, RetryPolicy: &temporal.RetryPolicy{MaximumAttempts: 5}}); err := workflow.ExecuteActivity(ctx, Charge, input).Get(ctx, &out)` |

Local activities (short, same-worker, no separate task): TS `proxyLocalActivities`,
Python `workflow.execute_local_activity`, Go `workflow.ExecuteLocalActivity`. Use only for
calls well under a few seconds.

## Non-retryable failures

| | Code |
|---|---|
| TS | `throw ApplicationFailure.nonRetryable("declined", "CardDeclined")` |
| Python | `raise ApplicationError("declined", type="CardDeclined", non_retryable=True)` |
| Go | `return temporal.NewNonRetryableApplicationError("declined", "CardDeclined", nil)` |

## Heartbeats

| | Code |
|---|---|
| TS | `Context.current().heartbeat({ offset })`; resume from `Context.current().info.heartbeatDetails` |
| Python | `activity.heartbeat(offset)`; resume from `activity.info().heartbeat_details` |
| Go | `activity.RecordHeartbeat(ctx, offset)`; resume with `activity.HasHeartbeatDetails` / `activity.GetHeartbeatDetails` |

## Time, timers, randomness

| | Now | Sleep | Random / UUID |
|---|---|---|---|
| TS | `Date.now()` (sandboxed, deterministic) | `await sleep("1 hour")` | `Math.random()` (sandboxed), `uuid4()` |
| Python | `workflow.now()` | `await asyncio.sleep(3600)` (becomes a durable timer inside workflow code) | `workflow.random()`, `workflow.uuid4()` |
| Go | `workflow.Now(ctx)` | `workflow.Sleep(ctx, time.Hour)` | `workflow.SideEffect(ctx, func(ctx workflow.Context) interface{} { return uuid.NewString() })` |

## Waiting for a condition

| | Code |
|---|---|
| TS | `const ok = await condition(() => approved, "24 hours")` (false on timeout) |
| Python | `await workflow.wait_condition(lambda: self.approved, timeout=timedelta(hours=24))` (raises `asyncio.TimeoutError` on timeout) |
| Go | `ok, err := workflow.AwaitWithTimeout(ctx, 24*time.Hour, func() bool { return approved })` |

## Signals, queries, updates

TS:
```ts
export const approve = defineSignal<[string]>("approve");
export const status = defineQuery<string>("status");
export const setLimit = defineUpdate<number, [number]>("setLimit");
setHandler(approve, (by) => { approvedBy = by; });
setHandler(status, () => state);
setHandler(setLimit, (n) => (limit = n), { validator: (n) => { if (n < 0) throw new Error("negative"); } });
```

Python:
```python
@workflow.defn
class Approval:
    @workflow.signal
    def approve(self, by: str) -> None: self.approved_by = by
    @workflow.query
    def status(self) -> str: return self.state
    @workflow.update
    def set_limit(self, n: int) -> int:
        self.limit = n
        return n
    @set_limit.validator
    def validate_limit(self, n: int) -> None:
        if n < 0: raise ValueError("negative")
```

Go:
```go
ch := workflow.GetSignalChannel(ctx, "approve")
workflow.SetQueryHandler(ctx, "status", func() (string, error) { return state, nil })
workflow.SetUpdateHandlerWithOptions(ctx, "setLimit",
    func(ctx workflow.Context, n int) (int, error) { limit = n; return n, nil },
    workflow.UpdateHandlerOptions{Validator: func(ctx workflow.Context, n int) error {
        if n < 0 { return errors.New("negative") }; return nil }})
```

Client side: TS `handle.signal(approve, "ann")`, `handle.query(status)`,
`handle.executeUpdate(setLimit, { args: [10] })`; Python `await handle.signal(Approval.approve, "ann")`,
`await handle.execute_update(Approval.set_limit, 10)`; Go `c.SignalWorkflow`,
`c.QueryWorkflow`, `c.UpdateWorkflow`.

## Patching

| | Add | Deprecate (after old executions finish) |
|---|---|---|
| TS | `if (patched("id")) { new } else { old }` | `deprecatePatch("id"); new` |
| Python | `if workflow.patched("id"): new else: old` | `workflow.deprecate_patch("id")` |
| Go | `v := workflow.GetVersion(ctx, "id", workflow.DefaultVersion, 1); if v == 1 { new } else { old }` | Raise min version: `workflow.GetVersion(ctx, "id", 1, 1)` |

Lifecycle: add patch, deploy, wait until no executions started before the patch remain
(use a visibility query on start time), deprecate, deploy, wait again, remove.

## Continue-as-new

| | Check | Do |
|---|---|---|
| TS | `workflowInfo().continueAsNewSuggested` | `await continueAsNew<typeof wf>(state)` |
| Python | `workflow.info().is_continue_as_new_suggested()` | `workflow.continue_as_new(state)` |
| Go | `workflow.GetInfo(ctx).GetContinueAsNewSuggested()` | `return workflow.NewContinueAsNewError(ctx, Wf, state)` |

Wait for in-flight signal and update handlers first (TS `await condition(allHandlersFinished)`,
Python `await workflow.wait_condition(workflow.all_handlers_finished)`, Go
`workflow.Await(ctx, func() bool { return workflow.AllHandlersFinished(ctx) })`).

## Test environments

| | Time-skipping env | Run |
|---|---|---|
| TS | `await TestWorkflowEnvironment.createTimeSkipping()` | `Worker.create({ connection: env.nativeConnection, ... })`, `worker.runUntil(...)` |
| Python | `async with await WorkflowEnvironment.start_time_skipping() as env:` | `async with Worker(env.client, task_queue=..., workflows=[...], activities=[...]):` |
| Go | `var s testsuite.WorkflowTestSuite; env := s.NewTestWorkflowEnvironment()` | `env.OnActivity(Charge, mock.Anything, mock.Anything).Return(out, nil); env.ExecuteWorkflow(Wf, in)` |

## Replay

| | Code |
|---|---|
| TS | `await Worker.runReplayHistory({ workflowsPath: require.resolve("./workflows") }, history)` |
| Python | `await Replayer(workflows=[OrderWorkflow]).replay_workflow(WorkflowHistory.from_json(wf_id, json_text))` |
| Go | `r := worker.NewWorkflowReplayer(); r.RegisterWorkflow(OrderWorkflow); err := r.ReplayWorkflowHistoryFromJSONFile(nil, "histories/order-123.json")` |

Export histories with `temporal workflow show --workflow-id <id> --output json`.

## Python sandbox note

Workflow modules are re-imported in a sandbox. Import non-deterministic or heavy modules
used only for types or activities through:

```python
with workflow.unsafe.imports_passed_through():
    from .activities import charge
    import pydantic
```

## Go-specific determinism

- Never use native goroutines, channels, `select`, or `time.After` in workflow code; use
  `workflow.Go`, `workflow.NewChannel`, `workflow.NewSelector`, `workflow.NewTimer`.
- Never range over a map to issue commands; sort the keys first.
- Run the `workflowcheck` static analyzer (from the Go SDK contrib tools) in CI to catch
  common determinism violations.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc