Skip to content

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.

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 flow state bag, the message history, and waitingFor (set while suspended).
  • StepRecord[] — the append-only log. Each record holds an index, an idempotency key, a kind (tool / approval / signal / now / uuid), and either a result or an error.

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.

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 only
appendStep({ 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).

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.

ctx.approve() and ctx.signal() suspend the run durably:

  1. If a matching record already exists (you are resuming past the pause), its payload is returned and execution continues.
  2. Otherwise the runtime sets waitingFor, flips status to paused, persists the run, emits a paused stream part, and throws a SuspendError. 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.

approval-flow.ts
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],
}),
],
});
  • 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 SessionStore and its retention.