← Back to the journal

Deterministic Patterns for Agentic Applications: A Practical Guide

Build reliable agent systems with validated proposals, pure state transitions, durable observations, scoped approvals, and transactional execution intents.

An agent can produce a useful plan and still be difficult to operate. It might repeat a write after a timeout, execute a proposal that changed after approval, or choose a different next step when a worker restarts.

The practical response is to put deterministic application logic around the parts that make uncertain decisions. Let the model interpret, propose, and explain. Give ordinary code responsibility for validating proposals, authorizing actions, advancing state, and recording what happened.

This guide develops that boundary through three TypeScript components using Node.js 22.18+ built-ins. They implement real validation, state transitions, and database operations. They do not call a model or pretend to implement a remote service. The code is statically typechecked, but the examples have not been executed or tested as an integrated application. Save the blocks as adjacent .mts files. Node.js 22.18+ runs their erasable TypeScript syntax directly; type stripping performs no typechecking or runtime input validation. The explicit validators remain necessary. Node.js TypeScript support

What determinism actually promises

Four terms describe different properties:

Property Useful meaning What it does not establish
Determinism The same explicit inputs and versioned logic produce the same result. The result is correct or authorized.
Reproducibility You can recreate a result under recorded conditions. Every external dependency will remain available.
Idempotency Repeating one logical operation does not produce additional intended effects. Two different operation identifiers represent the same intent.
Exactly-once effect An effect occurs once within a clearly specified boundary. A database transaction can atomically include an unrelated remote API.

Setting temperature to zero does not make an entire agent application deterministic. Model implementations, versions, tool results, retrieval indexes, concurrency, and time can still change. Structured output improves the shape of a response; it does not prove factual accuracy or permission to act.

Choose a narrower, useful invariant: given the same accepted event history and reducer version, reconstruct the same application state and pending intents. That is something your own code can define.

Anthropic distinguishes workflows with predefined orchestration from agents that dynamically direct their process. Both can use deterministic boundaries; the distinction concerns who chooses the next step, not whether validation and authorization are optional. Building effective agents

Put a deterministic shell around model proposals

A practical execution path is:

  1. Record the user’s request and authenticated execution scope.
  2. Ask the model for a proposal through your actual model integration.
  3. Parse and validate the returned data against a narrow contract.
  4. Resolve authorization from trusted application state.
  5. Record the accepted observation and advance the workflow.
  6. Commit any execution intent before dispatching its side effect.
  7. Record the observed outcome, including uncertainty.

A proposal is data. It should not carry the power to select its own tenant, approve itself, or install a new tool. Prefer a small set of named operations with explicit arguments over arbitrary shell commands or unrestricted network requests.

Component 1: validate a proposal and identify its exact content

Save this component as proposal.mts. It accepts a JSON file describing a local draft-saving proposal. The workspace argument comes from the caller’s trusted context, not from model output. This example deliberately supports one operation and a small string-only payload.

import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';

export type Action = Readonly<{
  schemaVersion: 1;
  workspace: string;
  tool: 'save_draft';
  title: string;
  body: string;
}>;

function object(value: unknown, keys: readonly string[]): Record<string, unknown> {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
    throw new Error('Expected a JSON object');
  }
  const record = value as Record<string, unknown>;
  if (Object.keys(record).length !== keys.length ||
      !keys.every(key => Object.hasOwn(record, key))) {
    throw new Error(`Expected exactly: ${keys.join(', ')}`);
  }
  return record;
}

export function validateAction(value: unknown): Action {
  const a = object(value, ['schemaVersion', 'workspace', 'tool', 'title', 'body']);
  if (a.schemaVersion !== 1 || a.tool !== 'save_draft') {
    throw new Error('Unsupported schema version or tool');
  }
  if (typeof a.workspace !== 'string' ||
      !/^[a-z0-9][a-z0-9-]{0,63}$/.test(a.workspace)) {
    throw new Error('Invalid workspace identifier');
  }
  if (typeof a.title !== 'string' || !a.title.trim() || a.title.length > 200) {
    throw new Error('Title must contain 1–200 UTF-16 code units');
  }
  if (typeof a.body !== 'string' || !a.body.trim() || a.body.length > 50_000) {
    throw new Error('Body must contain 1–50000 UTF-16 code units');
  }
  return Object.freeze({
    schemaVersion: 1, workspace: a.workspace, tool: 'save_draft',
    title: a.title, body: a.body,
  });
}

export function serializeAction(value: unknown): string {
  const a = validateAction(value);
  // Fixed field order, flat schema, no optional or numeric payload fields.
  return JSON.stringify({
    schemaVersion: a.schemaVersion, workspace: a.workspace,
    tool: a.tool, title: a.title, body: a.body,
  });
}

export function actionDigest(value: unknown): string {
  return createHash('sha256').update(serializeAction(value), 'utf8').digest('hex');
}

export function validateProposal(raw: string, workspace: string): Action {
  const p = object(JSON.parse(raw) as unknown, ['tool', 'title', 'body']);
  return validateAction({
    schemaVersion: 1, workspace, tool: p.tool, title: p.title, body: p.body,
  });
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const [file, workspace, ...extra] = process.argv.slice(2);
  if (!file || !workspace || extra.length) {
    throw new Error('Usage: node proposal.mts proposal.json workspace');
  }
  const action = validateProposal(readFileSync(file, 'utf8'), workspace);
  console.log(serializeAction(action));
  console.error(`Action digest: ${actionDigest(action)}`);
}

Run it against a real proposal file with node proposal.mts proposal.json editorial > action.json. Standard output contains the validated action; standard error reports its digest. It rejects extra fields, unknown tools, and invalid field types. It preserves text rather than silently rewriting it after validation. JavaScript’s JSON.parse retains the last value for duplicate object keys; this component does not reject duplicates. Approval and dispatch therefore use the validated, reserialized action, never an interpretation of the original JSON by a different parser.

The digest is stable for this flat, fixed-order contract and UTF-8 serialization. Both proposal and outbox components import the same serializer. It is not a universal cross-language JSON canonicalization specification. Keep the encoding rules versioned if another service must reproduce it. Limit request size at your ingress before loading untrusted files; the string limits count UTF-16 code units after parsing and are not a transport limit.

A matching hash is not an approval

Anyone who possesses an action can calculate its SHA-256 digest. The digest helps answer “Is this the action that was reviewed?” It cannot answer “Who authorized it?”

Store approval through an authenticated route. Bind the record to the action digest, workspace, approving actor, resource version, expiry, and allowed number of uses. When execution begins, resolve the caller’s current authority and verify those fields against trusted records.

Changing an attachment, destination, or draft body should invalidate approval when that field affects the approved action. Bind mutable resources to a version or immutable snapshot so the executor cannot quietly substitute newer content.

Single-use approval also needs concurrency control. Two workers must not redeem the same authorization independently. Consume it atomically with creation of the durable execution intent. If the remote result later becomes uncertain, reconcile that intent rather than requesting a fresh approval and accidentally repeating the operation.

The first component only validates and fingerprints an action. It intentionally grants no permission and performs no draft write.

Model the workflow as explicit transitions

A chat transcript is valuable evidence, but it is a poor substitute for an application state machine. A workflow should know whether it is waiting for review, ready for execution, completed, or awaiting reconciliation.

A pure reducer takes state and an event and returns new state. It must not read the clock, query a database, generate identifiers, or contact a model. Supply those observations through events when needed.

Component 2: a pure workflow reducer

Save this component as state.mts. It defines readonly records, freezes returned state objects, and uses an explicit transition table. Its records contain only scalar fields, so shallow freezing covers those records. Approval events must be created by the trusted authorization layer; their name does not make an untrusted incoming event authentic.

export type Phase =
  | 'new' | 'review' | 'ready' | 'executing'
  | 'completed' | 'uncertain' | 'rejected';
export type EventKind =
  | 'proposed' | 'approved' | 'rejected' | 'dispatch_started'
  | 'completed' | 'outcome_unknown'
  | 'reconciled_success' | 'reconciled_no_effect';
export type State = Readonly<{
  version: number;
  phase: Phase;
  actionDigest: string;
  receipt: string;
}>;
export type Event = Readonly<{
  kind: EventKind;
  expectedVersion: number;
  actionDigest: string;
  receipt: string;
}>;

const transitions: Readonly<Record<string, Phase>> = Object.freeze({
  'new/proposed': 'review',
  'review/approved': 'ready',
  'review/rejected': 'rejected',
  'ready/dispatch_started': 'executing',
  'executing/completed': 'completed',
  'executing/outcome_unknown': 'uncertain',
  'uncertain/reconciled_success': 'completed',
  'uncertain/reconciled_no_effect': 'ready',
});

export function initialState(): State {
  return Object.freeze({ version: 0, phase: 'new', actionDigest: '', receipt: '' });
}

export function reduce(state: State, event: Event): State {
  if (!Number.isSafeInteger(event.expectedVersion) ||
      event.expectedVersion < 0 || event.expectedVersion !== state.version ||
      state.version >= Number.MAX_SAFE_INTEGER) {
    throw new Error('Invalid, stale, or exhausted state version');
  }
  if (!/^[0-9a-f]{64}$/.test(event.actionDigest)) {
    throw new Error('Invalid action digest');
  }
  if (state.actionDigest && state.actionDigest !== event.actionDigest) {
    throw new Error('Event refers to a different action');
  }
  const key = `${state.phase}/${event.kind}`;
  const phase = Object.hasOwn(transitions, key) ? transitions[key] : undefined;
  if (!phase) throw new Error(`Invalid transition: ${key}`);
  const succeeds = event.kind === 'completed' || event.kind === 'reconciled_success';
  if (succeeds && !event.receipt.trim()) {
    throw new Error('Success requires a recorded receipt reference');
  }
  if (!succeeds && event.receipt) {
    throw new Error('This transition does not accept a receipt');
  }
  return Object.freeze({
    version: state.version + 1, phase,
    actionDigest: event.actionDigest, receipt: event.receipt,
  });
}

This component returns a new state without changing its input. Replaying the same valid event sequence reconstructs the same state under the same implementation.

It assumes deserialized records have passed the application’s runtime type validation. TypeScript annotations disappear at execution and do not validate JSON. This reducer is a library component: import initialState and reduce into the trusted event handler rather than casting arbitrary incoming data to Event. A receipt is a reference to recorded execution evidence, not proof invented by the model. Likewise, reconciled_no_effect must reflect an actual authoritative check; a timeout alone cannot justify returning to ready.

Repeated event delivery is a separate concern. Persist unique event identifiers and deduplicate before reduction. The version check prevents accepting a stale event but does not by itself distinguish a legitimate duplicate from a conflicting event.

Record observations before depending on them

A live model call belongs at the boundary, outside the reducer. Persist its accepted result before applying the resulting transition. Keep the operation identifier, validated output, schema version, model identifier, prompt version, and references to necessary inputs.

Record external observations too. Replaying a decision should not fetch today’s document and pretend it is the document the agent saw last week. Use retained snapshots or immutable references where your data policy permits them.

There is still a crash window between receiving a response and persisting it. If that response was lost, a new call may return a different answer. Your system should describe it as a new attempt, not as reconstruction of an observation it no longer has.

Temporal’s replay model requires workflow logic to remain compatible with its recorded history; nondeterministic work belongs outside that replay-sensitive logic. That principle is useful even when you are implementing a smaller system yourself. Temporal workflow definitions

State reconstruction and side-effect delivery must remain separate operations. Replaying events to inspect yesterday’s state should not resend yesterday’s messages. Durable workflow execution provides a model for tracking progress across interruptions, but your own integration still needs correct activity and external-effect semantics. Temporal workflow execution

Commit state and execution intent together

Consider a worker that updates a workflow to “queued” and then inserts a delivery job. A crash between those writes leaves a queued workflow with nothing to deliver. Reversing the writes creates the opposite inconsistency.

An outbox puts both changes in one database transaction. A separate dispatcher reads committed intents and performs their external effects. The transaction guarantees local consistency; it does not include the remote service.

Component 3: SQLite compare-and-swap with an outbox

Save this as outbox.mts. It accepts an already validated, authorized JSON action and atomically queues it against a specific workflow version. This standalone component does not implement approval storage or a dispatcher; invoke it only behind those application boundaries.

import { readFileSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { pathToFileURL } from 'node:url';
import { actionDigest, serializeAction, validateAction } from './proposal.mts';

const schema = `
CREATE TABLE IF NOT EXISTS workflows (
  id TEXT PRIMARY KEY,
  version INTEGER NOT NULL CHECK (version >= 0),
  phase TEXT NOT NULL CHECK (phase IN ('ready', 'queued'))
) STRICT;
CREATE TABLE IF NOT EXISTS outbox (
  operation_id TEXT PRIMARY KEY,
  workflow_id TEXT NOT NULL REFERENCES workflows(id),
  expected_version INTEGER NOT NULL,
  payload TEXT NOT NULL,
  digest TEXT NOT NULL,
  result_version INTEGER NOT NULL,
  delivered INTEGER NOT NULL DEFAULT 0 CHECK (delivered IN (0, 1))
) STRICT;
`;

export function enqueue(
  db: DatabaseSync, workflowId: string, expectedVersion: number,
  operationId: string, action: unknown,
): number {
  if (!workflowId.trim() || !operationId.trim()) {
    throw new Error('Identifiers must be nonempty');
  }
  if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0 ||
      expectedVersion >= Number.MAX_SAFE_INTEGER) {
    throw new Error('Expected version must permit a safe integer increment');
  }
  const payload = serializeAction(action);
  const digest = actionDigest(action);
  db.exec('BEGIN IMMEDIATE');
  try {
    const previous = db.prepare(`
      SELECT workflow_id, expected_version, payload, result_version
      FROM outbox WHERE operation_id = ?
    `).get(operationId);
    if (previous) {
      if (previous.workflow_id !== workflowId ||
          previous.expected_version !== expectedVersion || previous.payload !== payload) {
        throw new Error('Operation identifier reused for different intent');
      }
      const version = previous.result_version;
      if (typeof version !== 'number' || !Number.isSafeInteger(version)) {
        throw new Error('Stored result version is invalid');
      }
      db.exec('COMMIT');
      return version;
    }
    const changed = db.prepare(`
      UPDATE workflows SET version = version + 1, phase = 'queued'
      WHERE id = ? AND version = ? AND phase = 'ready'
    `).run(workflowId, expectedVersion);
    if (Number(changed.changes) !== 1) {
      throw new Error('Workflow missing, stale, or not ready');
    }
    const resultVersion = expectedVersion + 1;
    db.prepare(`
      INSERT INTO outbox
      (operation_id, workflow_id, expected_version, payload, digest, result_version)
      VALUES (?, ?, ?, ?, ?, ?)
    `).run(operationId, workflowId, expectedVersion, payload, digest, resultVersion);
    db.exec('COMMIT');
    return resultVersion;
  } catch (error) {
    try { db.exec('ROLLBACK'); }
    catch (rollbackError) {
      throw new AggregateError([error, rollbackError], 'Transaction and rollback failed');
    }
    throw error;
  }
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const [database, command, workflow, ...args] = process.argv.slice(2);
  if (!database || !workflow?.trim() ||
      !((command === 'create' && args.length === 0) ||
        (command === 'enqueue' && args.length === 3))) {
    throw new Error('Usage: node outbox.mts DB create WORKFLOW | ' +
      'node outbox.mts DB enqueue WORKFLOW VERSION OPERATION ACTION_FILE');
  }
  const db = new DatabaseSync(database, { timeout: 5000 });
  try {
    db.exec('PRAGMA foreign_keys = ON');
    db.exec(schema);
    if (command === 'create') {
      db.prepare("INSERT INTO workflows VALUES (?, 0, 'ready')").run(workflow);
      console.log('Created workflow at version 0');
    } else {
      const [versionText, operation, file] = args;
      if (!versionText || !/^\d+$/.test(versionText) || !operation || !file) {
        throw new Error('Supply integer VERSION, OPERATION, and ACTION_FILE');
      }
      const action = validateAction(JSON.parse(readFileSync(file, 'utf8')) as unknown);
      console.log(`Intent recorded at version ${
        enqueue(db, workflow, Number(versionText), operation, action)
      }`);
    }
  } finally {
    db.close();
  }
}

The create command establishes a real local workflow. The enqueue command takes the validated action file and checks its schema again. The command-line entry point is an administrative interface, not an authorization mechanism. This two-phase database model illustrates the commit boundary independently of the richer reducer above.

Walk through a concrete local intent

To connect the first and third components, create this proposal.json:

{
  "tool": "save_draft",
  "title": "Agent execution notes",
  "body": "Validate proposals before recording an execution intent."
}

Then run these commands in the directory containing the three saved components:

node proposal.mts proposal.json editorial > action.json
node outbox.mts agent.sqlite create notes-workflow
node outbox.mts agent.sqlite enqueue notes-workflow 0 save-notes-001 action.json

The intended result is one committed execution intent at version 1. Repeating only the last command should return that same version without inserting another intent. Changing the body and validating it again while reusing save-notes-001 should raise the conflicting-intent error. Creating the same workflow twice is an error, so do not repeat the create command when investigating retries.

These commands perform real local file and database writes. They record a request to save a draft; they do not create a draft in a CMS or execute a remote publishing action. The reducer is a separate component to import into your controller, where authenticated events and persisted state are already available. This walkthrough has not been executed as part of preparing the guide.

BEGIN IMMEDIATE obtains the write transaction before checking existing intent. The conditional update is compare-and-swap: advance only if the workflow is still at the expected version and phase. A duplicate operation returns its original result version; a changed payload under that identifier fails visibly.

The code uses DatabaseSync, prepared statements with bound parameters, and explicit transactions. Node’s SQLite API is experimental in Node 22 and these database operations are synchronous: they block the calling thread, including time spent waiting for locks. Keep this component in a CLI or dedicated worker when serving concurrent requests. Node.js 22 SQLite documentation

Do not hold this transaction open while calling a model or remote API. Retain outbox records through the retry and reconciliation window. Deleting deduplication evidence can make an old retry appear new.

Retries do not resolve unknown outcomes

A dispatcher can succeed remotely and crash before setting delivered. After restart, it sees unfinished work. Resending may duplicate the external effect.

Where the downstream API supports it, send a stable idempotency key derived from the logical operation, and reuse the identical payload on retries. AWS describes request identifiers as a way for callers to communicate repeated intent and for services to recognize retries. That guarantee depends on the API’s contract, including retention and conflict behavior. AWS on idempotent APIs

Without that contract, use an authoritative lookup or a reconciliation workflow. “Our request timed out” means the caller stopped waiting. It does not prove the server canceled the operation.

Bound retries by both attempt count and elapsed budget. Distinguish transient transport failures from invalid arguments or rejected authorization. Respect service backoff guidance, apply jitter, and record the next eligible attempt time. If replay must reconstruct scheduling exactly, retain the chosen delay rather than drawing randomness again.

Expose uncertainty as a state with an owner and next step. Silently treating it as failure invites duplicate work; silently treating it as success hides incomplete work.

Make concurrency rules explicit

Parallel workers make completion order variable. Assign stable task identifiers before dispatch. Persist each result against its task identifier, then aggregate in a defined order with explicit tie-breaking rules.

Sorting results makes their ordering stable; it does not make newly generated content identical. Nor does sorting remove uncertainty over which tasks finished before a deadline. Record the accepted result set and deadline outcome when those affect the decision.

Use atomic claims for outbox workers. A lease helps recover abandoned work, but its expiry does not stop the previous worker from completing a remote request. Downstream idempotency, fencing where supported, and reconciliation remain necessary.

For conflicting writes, define ordering per resource or use version checks. When compare-and-swap fails, reload state and reevaluate the intended transition. Do not simply overwrite the expected version and retry an approval bound to older content.

Version the behavior you need to reconstruct

Store schema, reducer, policy, prompt, and tool-contract versions where they influence decisions. A change to validation or transition rules can make old history incompatible with new code even if the database format still loads.

Keep historical replay behavior available or migrate history through a deliberate, documented process. Preserve original observations when transforming their representation. Snapshots can accelerate reconstruction, but record the event position and logic version they correspond to.

For a publishing agent, these patterns connect reviewed text to the exact publication intent. For a support agent, they separate a suggested resolution from an authorized account mutation. For a research agent, recorded source observations and stable aggregation make the final report traceable even when retrieval results change later.

Choose the smallest useful implementation

Application shape Start with Add when needed
One read-only model request Input validation and recorded output Source snapshots and reproducible evaluation
A reviewed write Immutable proposal and authenticated approval Version-bound execution intent
A background integration Durable state, operation keys, outbox Reconciliation and worker ownership
A long-running multi-step agent Versioned transitions and persisted observations Durable workflow orchestration and replay tooling

Keep the claim proportional to the boundary you actually control. A deterministic reducer gives predictable transitions. A transaction keeps related local writes together. An idempotency contract can make retries safe for a particular effect. Together, those properties make an agent easier to understand and recover without pretending that its model or the outside world has become deterministic.

← Explore the journal