Durable Execution
Every run in Kuralle is deterministically replayable. The runtime does not re-run a conversation from scratch when it resumes — it records each side effect (tool call, clock read, pause) in an append-only log and, on re-entry, replays those records instead of executing them again.
This is the same model used by durable workflow engines like Temporal and DBOS. It is what gives Kuralle two guarantees:
- Exactly-once tool execution — a payment tool won’t charge twice if a turn retries.
- Durable pause/resume — a run can suspend for human approval (or any external signal) and resume later, even in a different process, without losing its place.
The effect log
Section titled “The effect log”A run is stored as two things:
RunState— the mutable snapshot: status (running/paused/finished/error/aborted), the active agent, active flow and node, the flowstatebag, the message history, andwaitingFor(set while suspended).StepRecord[]— the append-only log. Each record holds anindex, an idempotencykey, akind(tool/approval/signal/now/uuid), and either aresultor anerror.
Both live inside the session under a durableRuns key, so durability is inherited from whichever SessionStore you configured — Memory in development, Redis or Postgres in production.
The run id equals the session id: there is exactly one durable run per session, and the run is the conversation.
Replay: record once, replay forever
Section titled “Replay: record once, replay forever”Every durable effect funnels through one decision:
// Conceptually, for each effect:const recorded = findStepByKey(steps, key);if (recorded) { if (recorded.error) throw recorded.error; // errors replay too return recorded.result; // cached — do NOT re-run}const result = await execute(); // first time onlyappendStep({ key, kind, result });return result;On the first pass an effect runs and its outcome is appended to the log. On every subsequent pass — a retry, a resume after a pause, or a later turn — the recorded outcome is returned without touching the outside world.
The RunContext exposes five durable effects, all built on this mechanism:
| Effect | Purpose |
|---|---|
| ctx.tool(name, args) | Run an effect tool, logged and replayed |
| ctx.approve(req) | Suspend for human approval (a built-in signal) |
| ctx.signal(name) | Suspend until an external signal is delivered |
| ctx.now() | Deterministic wall-clock read |
| ctx.uuid() | Deterministic id generation |
Inside action nodes, these are available on the ctx argument (ctx.tool, ctx.approve, ctx.signal, ctx.now, ctx.uuid, ctx.emit).
Idempotency keys
Section titled “Idempotency keys”Each effect’s key is a hash of { runId, callsite, payload }, where the callsite is the effect’s ordinal position in the run — the first effect is 0, the second 1, and so on. This is what makes replay align: the Nth effect this pass must match the Nth effect recorded last pass.
Pause and resume
Section titled “Pause and resume”ctx.approve() and ctx.signal() suspend the run durably:
- If a matching record already exists (you are resuming past the pause), its payload is returned and execution continues.
- Otherwise the runtime sets
waitingFor, flips status topaused, persists the run, emits apausedstream part, and throws aSuspendError. The host loop catches it and ends the turn cleanly — the run is now frozen on disk.
To resume, deliver a signal on a later run:
runtime.run({ sessionId, signalDelivery: { signalId: 'sup-123', name: '__approval', payload: { approved: true, by: 'supervisor' } },});recordSignalDelivery validates the name against waitingFor, dedupes by signalId, appends the payload as a record, and clears waitingFor. The run then re-executes from the top — but now the approval is in the log, so the previously-suspending ctx.approve() returns the delivered payload and execution flows past it. (ctx.approve() is just a signal named __approval.)
Example: a refund flow with an approval gate
Section titled “Example: a refund flow with an approval gate”The action node collects an amount, pauses for a supervisor decision, and runs the refund tool exactly once on approval.
import { openai } from '@ai-sdk/openai';import { defineAgent, defineFlow, action, collect, reply, defineTool } from '@kuralle-agents/core';import { z } from 'zod';
const processRefund = defineTool({ name: 'processRefund', description: 'Issue a refund to the customer', input: z.object({ amount: z.number() }), execute: async ({ amount }) => ({ refunded: amount }),});
const confirmed = reply({ id: 'confirmed', instructions: 'Tell the customer the refund was approved and processed, then end.', next: () => ({ end: 'refunded' }),});
const declined = reply({ id: 'declined', instructions: 'Tell the customer the refund was declined by a supervisor, then end.', next: () => ({ end: 'declined' }),});
// An `action` node runs no model turn. It pauses for human approval — a durable// signal that survives a process restart — then runs the refund tool exactly once.const issueRefund = action({ id: 'issue_refund', run: async (state, ctx) => { const amount = Number(state.amount);
// Suspends the run: status -> 'paused', a SuspendError unwinds the turn, // and the run is persisted. It resumes only when an `__approval` signal is // delivered via runtime.run({ signalDelivery }). const decision = await ctx.approve({ title: `Approve $${amount} refund?`, description: `Customer requested a refund of $${amount}.`, });
if (!decision.approved) { return { goto: declined }; }
// Recorded in the effect log. If the run resumes after this point, the // recorded result is replayed instead of charging the customer twice. const receipt = await ctx.tool('processRefund', { amount }); return { goto: confirmed, data: { receipt } }; },});
const collectAmount = collect({ id: 'collect_amount', schema: z.object({ amount: z.number() }), required: ['amount'], instructions: (missing) => `Ask the customer for: ${missing.join(', ')}`, onComplete: () => issueRefund,});
export const refundAgent = defineAgent({ id: 'refunds', instructions: 'You process customer refund requests.', model: openai('gpt-4o-mini'), tools: { processRefund }, flows: [ defineFlow({ name: 'refund', description: 'Collect a refund amount, get supervisor approval, then refund', start: collectAmount, nodes: [collectAmount, issueRefund, confirmed, declined], }), ],});Two sharp edges
Section titled “Two sharp edges”- Errors are frozen, not retried. A failed effect’s error is recorded and re-thrown on every replay — a resumed run will not get a second attempt. Put retry logic inside the tool’s
execute, or start a fresh logical step (different args produce a different key). - The log grows with the session. Steps accumulate in the session for the life of the conversation. For long-lived sessions with many tool calls, account for this when choosing a
SessionStoreand its retention.
Related
Section titled “Related”- Tools — how
toolswires the durable executor. - Flow Execution Model —
actionnodes, where you callctx.approve/ctx.tooldirectly. - Sessions & State — the backend that persists the run log.