Agents · 2026-05-20 · By RedDB team · 5 min read

Hooks that mutate state — making Claude Code hooks transactional with RedDB

A PostToolUse audit-log hook looks trivial until the agent crashes mid-turn, the network blips, or Slack 503s. Wrap the side effects in a RedDB transaction with idempotency keys and a saga for the external call, and the hook stays honest.

What a hook actually does

Claude Code fires a handful of events at the shell — SessionStart, PreToolUse, PostToolUse, Stop, a few more — and lets you bind each one to a command. The pattern most teams reach for first is an audit log: every tool call gets a row somewhere, so you can later answer “what did the agent touch on this branch?” without re-reading the transcript.

Sketched as a one-liner, the hook looks fine:

#!/usr/bin/env bash
payload="$(cat)"
curl -sS "$REDDB_URL/audit/insert" -d "$payload"

Production-fine it is not. Three failure modes:

  1. The hook is killed mid-flight. The agent crashed, the user hit Ctrl-C, the laptop slept. The row may or may not have been written; nothing in the system knows.
  2. The harness retries. Claude Code does not today, but a queue in front of the hook (k8s, systemd, your own wrapper) will. Two identical inserts arrive and you have phantom audit rows.
  3. The hook fans out to a second system. Post the same audit line to Slack, write a Jira ticket, push to Datadog — and now you have a distributed transaction with no coordinator.

A single RedDB transaction solves (1) and (2). A saga solves (3). The combined recipe fits in one hook script.

The schema

One table for the audit row, one table for the idempotency keys, one table for the saga state. All three are append-only except saga.status.

CREATE TABLE audit_event (
  id            TEXT PRIMARY KEY,                -- ulid, generated client-side
  session_id    TEXT NOT NULL,
  hook_event    TEXT NOT NULL,                   -- PostToolUse, Stop, …
  tool_name     TEXT,                            -- Read, Bash, Skill, …
  tool_input    JSONB NOT NULL,
  is_error      BOOLEAN NOT NULL DEFAULT false,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE hook_idempotency (
  key           TEXT PRIMARY KEY,                -- hash(session_id + tool_use_id)
  event_id      TEXT NOT NULL REFERENCES audit_event(id),
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE saga (
  id            TEXT PRIMARY KEY,                -- ulid
  event_id      TEXT NOT NULL REFERENCES audit_event(id),
  step          TEXT NOT NULL,                   -- 'slack', 'datadog', …
  status        TEXT NOT NULL,                   -- pending | done | failed | compensated
  attempt       INTEGER NOT NULL DEFAULT 0,
  next_run_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  payload       JSONB NOT NULL,
  last_error    TEXT
);

CREATE INDEX audit_session_idx ON audit_event (session_id, created_at DESC);
CREATE INDEX saga_pending_idx  ON saga (status, next_run_at) WHERE status = 'pending';

hook_idempotency.key is what makes the hook safe to retry. The Claude Code payload includes a tool_use_id per call; the hash of session_id + tool_use_id is unique per invocation and stable across retries. Two hook executions with the same key see a PK violation on the second insert and bail out cleanly.

The transactional insert

RedDB exposes single-statement transactions via a /tx endpoint that accepts an array of SQL statements and applies them atomically. The hook posts both rows in one call:

#!/usr/bin/env bash
# ~/.claude/hooks/audit-log.sh
set -euo pipefail

payload="$(cat)"

session_id="$(jq -r '.session_id'                <<<"$payload")"
hook_event="$(jq -r '.hook_event_name'           <<<"$payload")"
tool_use_id="$(jq -r '.tool_use_id // .id // ""' <<<"$payload")"
tool_name="$(jq  -r '.tool_name // ""'           <<<"$payload")"
tool_input="$(jq  '.tool_input // {}'            <<<"$payload")"
is_error="$(jq  -r '.is_error // false'          <<<"$payload")"

event_id="$(ulid)"
idem_key="$(printf '%s:%s' "$session_id" "$tool_use_id" | sha256sum | awk '{print $1}')"

curl -sS --fail --max-time 1 \
  -H "Authorization: Bearer $REDDB_TOKEN" \
  -H "Content-Type: application/json" \
  "$REDDB_URL/tx" \
  -d "$(jq -nc \
        --arg eid       "$event_id" \
        --arg sid       "$session_id" \
        --arg ev        "$hook_event" \
        --arg tname     "$tool_name" \
        --argjson tin   "$tool_input" \
        --argjson err   "$is_error" \
        --arg ikey      "$idem_key" \
        '{
          statements: [
            { sql: "INSERT INTO audit_event (id, session_id, hook_event, tool_name, tool_input, is_error) VALUES ($1,$2,$3,$4,$5,$6)",
              args: [$eid, $sid, $ev, $tname, $tin, $err] },
            { sql: "INSERT INTO hook_idempotency (key, event_id) VALUES ($1,$2) ON CONFLICT (key) DO NOTHING",
              args: [$ikey, $eid] }
          ]
        }')" >/dev/null || true

echo '{"continue":true}'

Three details earn their lines:

  • The two INSERTs share one transaction. Either both land or neither does — no orphaned audit_event row pointing at no idempotency key, and no key pointing at a non-existent event.
  • ON CONFLICT (key) DO NOTHING makes the hook re-entrant. A retried call inserts the audit_event row, sees the idempotency conflict, and the whole transaction rolls back. The earlier row stays as the canonical one.
  • --max-time 1 plus || true. Audit logging must never block the agent. If RedDB is down for a second, the hook drops that one event rather than stalling the turn. The reason this is acceptable is that the next subsection picks the dropped event back up.

If your environment cannot tolerate even a dropped event, the cure is to write to a local SQLite WAL first and let a background process drain it into RedDB. That moves the durability boundary one hop closer to the agent at the cost of a second store.

Idempotency, demonstrated

A quick way to convince yourself the recipe is correct is to invoke the hook twice with the same payload and check the count:

$ echo "$payload" | ~/.claude/hooks/audit-log.sh
$ echo "$payload" | ~/.claude/hooks/audit-log.sh

$ psql -c "SELECT count(*) FROM audit_event
           WHERE id IN (
             SELECT event_id FROM hook_idempotency
             WHERE key = '$(printf %s "$session_id:$tool_use_id" | sha256sum | awk "{print \$1}")'
           )"
 count
-------
     1

One row, not two. The second hook’s transaction was rolled back by the idempotency conflict.

The saga: fanning out to external systems

The audit row is durable now. What about the Slack post that some teams want for every error tool call? You cannot put a Slack POST inside the RedDB transaction — different system, no two-phase commit. The standard answer is a saga: record the intent atomically with the audit row, run the external call separately, mark the saga step done or failed, and let a compensator deal with permanent failures.

Extend the transaction:

{
  "statements": [
    { "sql": "INSERT INTO audit_event …", "args": [/* … */] },
    { "sql": "INSERT INTO hook_idempotency …", "args": [/* … */] },
    { "sql": "INSERT INTO saga (id, event_id, step, status, payload) VALUES ($1, $2, 'slack', 'pending', $3)",
      "args": [/* saga_id */, /* event_id */, /* slack payload */] }
  ]
}

Now the audit row, the idempotency key, and the saga step land together or not at all. A separate worker drains the saga table:

-- The worker pulls one pending step at a time with row-level locking
-- so multiple workers can run safely.
BEGIN;

SELECT id, step, payload, attempt
  FROM saga
 WHERE status = 'pending'
   AND next_run_at <= now()
 ORDER BY next_run_at
 LIMIT 1
   FOR UPDATE SKIP LOCKED;

-- worker runs the step…

-- On success:
UPDATE saga SET status = 'done' WHERE id = $1;

-- On retryable failure (network blip, 5xx):
UPDATE saga
   SET attempt = attempt + 1,
       next_run_at = now() + (interval '1 second' * power(2, attempt)),
       last_error = $2
 WHERE id = $1;

-- On terminal failure (e.g. 4xx after N attempts):
UPDATE saga SET status = 'failed', last_error = $2 WHERE id = $1;

COMMIT;

FOR UPDATE SKIP LOCKED is the load-bearing piece. It turns the table into a work queue without a second piece of infrastructure. The worker holds a lock on the row it picked, every other worker skips that row, and the exponential backoff (power(2, attempt)) limits the damage a flapping downstream can do.

What about the compensator? If a Slack post fails terminally, the audit row should not be deleted — the tool call really did happen. The compensation is to write a second audit row noting the failed notification, which is itself a transactional insert:

INSERT INTO audit_event (id, session_id, hook_event, tool_name, tool_input)
VALUES (ulid(), $1, 'SagaFailed', 'slack',
        jsonb_build_object('original_event', $2, 'reason', $3));

The invariant the saga upholds is no silent loss. Either the external system gets the message, or there is a permanent record of why it did not.

What this buys you

FailureNaïve hookTransactional hook
Hook killed mid-writePartial state, no recordEither fully applied or nothing at all
Harness retriesDuplicate rowsIdempotency PK rejects the second
RedDB momentarily unreachableLost row, no logLost row, logged in stderr; replay job picks it up
Slack/Jira/Datadog 503Hook hangs the turn, or row missingSaga step retried with backoff out-of-band
Slack 401 (permanently broken)Silent failuresaga.status = 'failed' + compensating audit row

The hook script grew from one curl to about thirty lines. The schema grew by two tables. In exchange the audit log becomes the thing it claims to be — a trustworthy record of every tool call, with a clean story for every failure mode the network and the harness can throw at it.

What is next in this pillar

  • A /remember-shaped slash command that uses the same idempotency table so re-issuing a memory write does not create duplicate rows.
  • Cross-session task queues built on the same FOR UPDATE SKIP LOCKED pattern, so AFK agents can resume from a checkpoint after a crash.
  • An MCP server that exposes audit.* to every MCP-compatible CLI, so the same transactional guarantees apply when Cursor or Codex writes through.

The Atom feed is the way to be told when those land.

© 2026 RedDB.io. AGPL-3.0 self-host · Managed Cloud.