> Fetch the index of every Tonbo docs page as Markdown at https://tonbo.dev/llms.txt

# Run side effects once

Last updated 29 October 2026

Use this pattern when your agent sends an email, a payment, a message or any API call that must not happen twice: record it in the database before you make it, so an agent that crashes, restarts or is replaced by a second instance makes it once.

Recovering the agent’s state is the easy half: another host opens the database and finds every commit `synced()` confirmed. The hard half is a call the agent made and never recorded. Without a record, the next run cannot tell whether the email went out, and sends it again.

## Record, sync, call, record

Keep a table of effects, one row per effect, keyed by an idempotency key that you derive from the task, such as its id and the step, never from a random value. A re-run of the same task then derives the same key, and the provider recognises the repeat.

**effects.mjs**

```js
import { open } from "@tonbo/sqlite-ursula/tonbo";

const db = await open("agent-42");
db.exec(
  "CREATE TABLE IF NOT EXISTS effects (" +
    "key TEXT PRIMARY KEY, " + // derived from the task, never random
    "request TEXT NOT NULL, " + // what to send, as JSON
    "result TEXT)", // the provider's answer, NULL until recorded
);

async function perform(key, request, recordState) {
  // 1. The intent, and the state that led to it, in one transaction.
  db.exec("BEGIN");
  try {
    recordState();
    db.prepare("INSERT OR IGNORE INTO effects (key, request) VALUES (?, ?)")
      .run(key, JSON.stringify(request));
    db.exec("COMMIT");
  } catch (error) {
    if (db.isTransaction) db.exec("ROLLBACK");
    throw error;
  }
  // 2. The intent is durable and this process still owns the database.
  //    Throws UrsulaResetError (kind "fenced") if another instance took over.
  await db.synced();
  const { result } = db.prepare("SELECT result FROM effects WHERE key = ?").get(key);
  return result ?? send(key, request);
}

async function send(key, request) {
  // 3. The call, with the key the provider deduplicates on.
  const response = await fetch("https://api.example.com/v1/emails", {
    method: "POST",
    headers: { "Content-Type": "application/json", "Idempotency-Key": key },
    body: JSON.stringify(request),
  });
  if (!response.ok) throw new Error(`the provider answered ${response.status}`);
  // 4. The answer, recorded and made durable.
  const result = await response.text();
  db.prepare("UPDATE effects SET result = ? WHERE key = ?").run(result, key);
  await db.synced();
  return result;
}
```

1. In one transaction, record the intent with its key and the state that led to it. A crash before the commit leaves neither, and the re-run derives the same key.
2. `await db.synced()`. It resolves once Tonbo holds the intent and a round trip that started after the call found this process still the writer. If it throws `UrsulaResetError` with the kind `fenced`, another instance now writes the database: stop, and do not make the call.
3. Call the provider with the key, for an HTTP API in the [`Idempotency-Key`](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) header.
4. Record the answer in a transaction, then `synced()`.
5. On start, and after taking over, resolve every intent that has no answer.

`tasks` stands for your agent’s own state, which the same transaction updates:

```js
await perform(`task-${task.id}/welcome-email`, { to: task.email, template: "welcome" }, () =>
  db.prepare("UPDATE tasks SET step = 'welcomed' WHERE id = ?").run(task.id),
);
```

## Why synced() comes first

A commit returns once it is on this host. If the call went out before Tonbo held the intent, a crash of the host could lose the intent while the email stays sent, and the instance that takes over would find no trace of it. With `synced()` first, every call the provider sees has an intent in the database, so the next instance knows to check it. `synced()` also confirms ownership: an instance that has been replaced learns it there, before it acts.

While Tonbo is unreachable, `synced()` waits rather than throws, so the call waits with it. Pass `signal` to bound the wait (see [Connect](https://tonbo.dev/docs/databases/connect.md#synced)).

## After a crash or a second instance

An intent without an answer means the call may or may not have happened. Resolve each one before the agent carries on:

```js
// 5. On start, and after taking over: resolve every intent without a result.
await db.synced();
const unresolved = db.prepare("SELECT key, request FROM effects WHERE result IS NULL").all();
for (const { key, request } of unresolved) {
  await send(key, JSON.parse(request));
}
```

Where the provider deduplicates on the key, send again with the same key, and it returns the first answer instead of acting twice. Where it does not, or the key may have expired, ask the provider for the effect’s status first, such as by looking the key up in its API, and send only if it finds nothing.

## Two instances

One process writes a database at a time. When a second instance opens it, the first one’s commits are refused from then on: they no longer reach Tonbo, its next `synced()` throws `UrsulaResetError` with the kind `fenced`, and once it knows, its statements throw too (see [When two instances run](https://tonbo.dev/docs/databases/how-it-works.md#takeover)). The error’s action is `stop`: end the old instance rather than open the database again, which would take it back.

So two instances that both act become one that stops at step 2, before its call. The refusal stops writes to the database, not the old instance’s code: if it passed step 2 just before the second instance opened the database, its call still goes out. The new instance finds the same intent with no answer and resolves it with the same key, which is why the key matters even with one owner.

## What this does not guarantee

- Exactly once on its own. Between the last `synced()` and the call, a crash or a second instance leaves an intent whose call may or may not have happened. The provider’s idempotency key, or its status, decides; the ledger only makes sure the question is asked.
- Keys that last. Many providers honour an idempotency key only for a limited time, often between an hour and a day: Stripe, for example, may remove a key once it is 24 hours old and then treats a request that reuses it as new (see its [idempotent requests](https://docs.stripe.com/api/idempotent_requests)). Check yours. Resolve an older intent by its status, not by sending again.
- Providers with neither a key nor a status. A status check can also race an old instance whose call is still in flight. For such a call, at most once (record, sync, then never retry) or at least once (retry) is the choice left.
- More than `synced()` promises. An intent or an answer is durable once a `synced()` made after it resolves, and not before (see [When a commit is durable](https://tonbo.dev/docs/databases/how-it-works.md#commit)).
