{"slug": "make-agent-callable-writes-idempotent-or-lose-data", "title": "Make agent-callable writes idempotent, or lose data", "summary": "An engineer from Frihet, a company building an agent-native ERP, explains that agent-callable write operations must be idempotent to prevent data loss and duplicate records. The post details how agents retry failed requests, making duplicate writes common, and demonstrates an atomic idempotency-key pattern using Postgres or KV stores to ensure each logical operation executes only once.", "body_md": "An agent-native product isn't a chatbot bolted onto a CRUD app — it's an MCP server that lets an agent *do* things. Read invoices, create expenses, mark a client overdue. The demo is easy. The part that decides whether the thing survives contact with real traffic is the layer nobody screenshots: what happens when a write is sent twice.\n\nThis is the unglamorous half of agent-native engineering. Every write operation an agent can invoke needs idempotency, safe retries, and typed recoverable errors — not as a nice-to-have, but as the difference between a product and a liability. Here's why, and how to build it.\n\nDistributed systems people have known forever that networks give you at-least-once delivery, not exactly-once. A request goes out, the response gets lost on the way back, the caller doesn't know if the write landed, so it retries. With a human clicking a button, this is rare enough to hand-wave.\n\nAgents remove the hand-wave. Three things about how agents call tools push duplicate writes from \"edge case\" to \"Tuesday\":\n\n`429`\n\nand `5xx`\n\n. The Frihet MCP server (`fri_`\n\nkey, and its own client retries with exponential backoff when it hits that ceiling. A retry after a `429`\n\nis MCP itself does not save you here. A tool call is just RPC over JSON — the protocol says nothing about whether invoking `create_invoice`\n\ntwice creates one invoice or two. That contract is entirely yours to define on the server. If you don't define it, you've defined it as \"two.\"\n\nFor a read tool, a duplicate is free. For a write tool in a fiscal domain — an ERP that prepares VeriFactu records, IVA and IRPF filings — a duplicate is a phantom invoice in someone's tax quarter. The blast radius is exactly why the boring layer matters.\n\nThe fix is to let the caller assign a stable identity to a *logical* operation, independent of how many times it's physically delivered. That's an idempotency key: a client-generated token (usually a UUID) that stays the same across retries of the same intended write, and changes for a genuinely new one.\n\nThe rule for the client is simple: **generate the key once, at the point of intent, and reuse it for every retry of that same intent.** If the agent decides to bill a client, it mints one key and carries it through all four delivery attempts. When it decides to bill a *different* client, it mints a new one.\n\nThe server's job is to make the key mean something: the *first* time it sees a key, do the work and remember the result; every subsequent time, return the remembered result without touching the database again.\n\nThe subtlety that trips people up is atomicity. \"Check if the key exists, then insert it\" is a race — two concurrent retries both read \"not found\" and both do the work. The claim has to be a single atomic operation, which in Postgres is an `INSERT ... ON CONFLICT DO NOTHING`\n\nand in most KV stores a compare-and-set.\n\n``` js\nimport { createHash } from \"node:crypto\";\n\ninterface WriteContext {\n  accountId: string;      // scope keys per tenant — never globally\n  toolName: string;       // e.g. \"create_invoice\"\n  idempotencyKey: string; // client-supplied, stable across retries\n}\n\nasync function runOnce<T>(\n  ctx: WriteContext,\n  payload: unknown,\n  work: () => Promise<T>,\n): Promise<T> {\n  const slot = `${ctx.accountId}:${ctx.toolName}:${ctx.idempotencyKey}`;\n  const fingerprint = createHash(\"sha256\")\n    .update(JSON.stringify(payload))\n    .digest(\"hex\");\n\n  // Atomic claim: only the FIRST caller inserts the row.\n  const claim = await store.claim(slot, fingerprint); // INSERT ... ON CONFLICT DO NOTHING\n  if (!claim.inserted) {\n    // Key already seen. Same body  -> return the stored result.\n    // Different body -> the client reused a key for a new request. Reject.\n    if (claim.fingerprint !== fingerprint) {\n      throw new ToolError(\"idempotency_key_reused\", {\n        retryable: false,\n        message: \"Idempotency key was reused with a different payload.\",\n      });\n    }\n    return store.awaitResult<T>(slot); // return the SAME result, never re-run\n  }\n\n  try {\n    const result = await work();\n    await store.complete(slot, result);\n    return result;\n  } catch (err) {\n    await store.fail(slot); // release the slot so a genuine retry can proceed\n    throw err;\n  }\n}\n```\n\nThree things this snippet gets right that naive versions miss:\n\n`accountId`\n\nin the slot, one customer's retry could return another's invoice. Scope aggressively.How long do you keep the record? Long enough to outlive any retry storm — hours to a day is typical, and it's a deliberate trade-off, not a default. Too short and a slow retry slips through the window and double-writes; too long and you're storing every request body forever.\n\n`try/catch`\n\nIdempotency makes retrying *safe*. It doesn't tell you *when* to retry. That decision depends entirely on what went wrong, and \"an error happened\" is not enough information to make it.\n\n`429`\n\nor `503`\n\nis transient — back off and retry.`422 validation_failed`\n\nis permanent — retrying the same bad payload a thousand times just burns your rate limit against a wall.`504`\n\ngateway timeout is the genuinely hard one: the write For the agent to make this call, the error has to be machine-readable. A stringified `500 Internal Server Error`\n\nforces the model to guess from prose, and models guess wrong. Ship a typed error taxonomy instead: a stable `code`\n\n, an explicit `retryable`\n\nboolean, and a hint for how long to wait.\n\n```\ntype ErrorCode =\n  | \"rate_limited\"          // 429 — back off, then retry\n  | \"upstream_timeout\"      // 504 — retry with the SAME idempotency key\n  | \"validation_failed\"     // 422 — do NOT retry; the payload is wrong\n  | \"insufficient_funds\"    // 402 — do NOT retry; surface to the user\n  | \"idempotency_key_reused\"//     — client bug; do NOT retry\n  | \"conflict\";             // 409 — a concurrent write won; re-read, then decide\n\nclass ToolError extends Error {\n  constructor(\n    public readonly code: ErrorCode,\n    public readonly meta: {\n      retryable: boolean;\n      message: string;\n      retryAfterMs?: number;\n    },\n  ) {\n    super(meta.message);\n  }\n}\n\n// The client/agent reads `retryable` — it never parses a stringified 500.\nfunction isRetryable(err: unknown): err is ToolError {\n  return err instanceof ToolError && err.meta.retryable;\n}\n```\n\nPair the taxonomy with exponential backoff *and jitter*. Backoff without jitter means every one of your fanned-out tool calls that got rate-limited retries at the same instant — a synchronized stampede that trips the limit again. Randomizing the delay spreads the herd out. And cap the attempts: retryable does not mean retry forever.\n\nNote that `retryable`\n\nlives on the server's side of the contract but is *consumed* by the client. That's the point — the server knows whether a write is safe to repeat, so it says so explicitly instead of making every caller reverse-engineer the answer from an HTTP status and a hope.\n\nA demo proves an agent *can* create an invoice. Production asks a harder question: when the same instruction gets delivered twice — because a network blipped, a session resumed, or a `429`\n\ntriggered the retry the client was designed to perform — does your customer end up with one invoice or two?\n\nConsider the surface area. A server like Frihet exposes 157 tools over `mcp.frihet.io`\n\n, a large share of them writes into an ERP that generates real tax records. Every one of those write tools is a place where at-least-once delivery meets a fiscal side effect. Idempotency keys, atomic server-side dedup, and typed recoverable errors are what turn that surface from a duplicate-data generator into something an agent can hammer safely at 100 requests a minute. That contract is the argument of this article: it is what every server on the other end of an agent owes its callers. It is also the least glamorous work in the codebase and the work that determines whether the product is trustworthy.\n\nThe industry spent a decade learning this for payments — it's why Stripe made idempotency keys a first-class header. Agents make the lesson urgent everywhere at once, because agents retry, resume, and fan out by default. If you're building anything an agent can write to, treat this as the foundation, not the polish. The chatbot is the part users see. This is the part that keeps their data correct.\n\n*Next up: designing tool schemas an agent won't misuse — types, enums, and error contracts as the real interface.*", "url": "https://wpnews.pro/news/make-agent-callable-writes-idempotent-or-lose-data", "canonical_source": "https://dev.to/frihet/make-agent-callable-writes-idempotent-or-lose-data-2n5m", "published_at": "2026-08-02 08:00:11+00:00", "updated_at": "2026-08-02 08:12:48.104303+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["Frihet", "MCP", "Postgres"], "alternates": {"html": "https://wpnews.pro/news/make-agent-callable-writes-idempotent-or-lose-data", "markdown": "https://wpnews.pro/news/make-agent-callable-writes-idempotent-or-lose-data.md", "text": "https://wpnews.pro/news/make-agent-callable-writes-idempotent-or-lose-data.txt", "jsonld": "https://wpnews.pro/news/make-agent-callable-writes-idempotent-or-lose-data.jsonld"}}