Your AI Agent Will Retry. Your Booking System Will Not Notice. A developer describes an intent-log pattern for preventing AI agents from duplicating writes to legacy systems of record. The approach routes agent tool calls through a wrapper that writes a normalized, business-intent-keyed row to a Postgres tool_intents table and returns, while a separate worker executes the intent against the downstream system, treating timeouts as an explicit 'unknown' state rather than a failure to retry. The author notes that idempotency keys derived from raw request bodies miss model-generated argument variations, and that many legacy SOAP, ERP and reservation backends have no idempotency concept at all. Letting an agent write to a legacy system of record takes an intent log, not a better prompt. The agent calls create booking . The legacy API takes 31 seconds and the HTTP client times out. The orchestrator retries the step. The second call succeeds, and the customer now holds two reservations and a confused confirmation email. The prompt was fine. The model was fine. Nothing in the stack knew that the first call had actually gone through. Every agent pilot we look at passes its demo on read-only tools: search, lookup, summarize. The trouble starts with the first tool that changes state. Booking, order entry, ticket creation, ERP postings. That is the write path, and it needs the same engineering you would give a payment flow. The first layer is the HTTP client. Most SDKs retry on connection errors and 5xx responses by default. The second is the orchestrator. Whether you use LangGraph, Temporal, a queue worker, or a loop you wrote yourself, a failed step gets re-run, and a redelivered message gets processed twice. At-least-once delivery is the default contract of nearly every queue you will use. The third layer is unique to agents. The model reads a timeout or an ambiguous response, decides the action probably failed, and calls the tool again. This retry is not byte-identical to the first. The arguments may differ in small ways: "adults": 2 versus "adults": "two" , a date in a different format, an extra optional field. An idempotency key derived from the raw request body will not catch it. Stripe popularized client-supplied idempotency keys for exactly this problem, and they work well when the downstream system honors them. Many systems of record do not. SOAP interfaces, batch-file ERPs, and older reservation backends often have no idempotency concept at all. Some will happily create a duplicate and return a fresh ID each time. The most damaging bug in these systems is a tool wrapper that turns a timeout into the string "Error: request failed" . The model reads that as a failure and does what a helpful assistant does: it tries again. The honest state after a timeout is unknown. The request may have been rejected, may still be queued, or may have committed. Treat it as a fourth outcome next to success, rejection, and error. The agent should receive "outcome pending, do not retry" and a reconciler should find out what happened. We apply the same discipline to our own internal tooling. Our outreach pipeline runs agents that draft and schedule emails, and the sender records a marker per lead and per email step, checked before every send. The agent never decides whether an email already went out. A file on disk does. The mechanism is almost embarrassingly low-tech, and it has survived crashed runs and re-triggered cron jobs because the check lives outside the model. Production systems need the same property with a stronger store behind it. The pattern is small. The agent never calls the legacy system directly. It calls a tool wrapper that writes an intent row to Postgres and returns. A separate worker executes intents against the downstream system. CREATE TABLE tool intents intent key text PRIMARY KEY, run id uuid NOT NULL, tool text NOT NULL, args jsonb NOT NULL, state text NOT NULL CHECK state IN 'pending','sent','confirmed','rejected','unknown' , external ref text, created at timestamptz NOT NULL DEFAULT now , updated at timestamptz NOT NULL DEFAULT now ; The key is computed from business intent, not from the request: js const parsed = BookingArgs.parse rawArgs ; // zod: "two" becomes 2, dates become ISO const intentKey = sha256 run.id, 'create booking', stableStringify parsed .join '|' ; const res = await db.query INSERT INTO tool intents intent key, run id, tool, args, state VALUES $1, $2, 'create booking', $3, 'pending' ON CONFLICT intent key DO NOTHING RETURNING intent key , intentKey, run.id, parsed , ; if res.rowCount === 0 return loadPriorResult intentKey ; // same intent, same answer Schema validation does the normalization work. Once the model's arguments have been coerced into a strict type, two retries that mean the same thing hash to the same key, and the second insert hits the primary key and returns the earlier result. If a run can legitimately perform the same action twice, add the plan step or line item ID to the hash so the two intents differ on purpose. The worker claims rows with SELECT ... FOR UPDATE SKIP LOCKED , which lets several workers drain the table without double-claiming. Writing the intent and enqueuing the work is one transaction, which is the transactional outbox pattern. Before it calls the legacy API, it writes the intent key into whatever free-text reference field the system offers. Almost every booking or ERP interface has one. When the worker's call times out, the intent moves to unknown . A reconciler job then queries the legacy system for a record carrying that reference. If it finds one, the intent becomes confirmed with the external ID. If nothing appears after a defined window, it becomes rejected and can be retried safely under the same key, because the reconciler has verified that nothing exists. Meanwhile the agent sees a structured result such as {"status":"pending","intent":"..."} . A model told that a booking is pending will tell the customer it is being processed. A model told that a call failed will try something else. Give the model hold booking and confirm booking instead of one create booking . A hold carries a TTL and expires on its own, so a wrong hold costs nothing. Confirmation is the irreversible step, and it can sit behind a policy check, an amount threshold, or a human approval click. Our own outreach pipeline works this way: agents draft freely, and a person approves before the first email to a new contact goes out. This split also gives you a clean place to enforce limits. The model can propose a thousand holds. Only a bounded number can ever become confirmations. None of this needs an ML team. It is Postgres, a worker process, schema validation, and a reconciler, which makes it a solid fit for one senior backend engineer working in NestJS or Python and owning the integration boundary. The unglamorous parts, such as the reconciler and the monitoring on unknown intents, decide whether the agent can be trusted with real customers. Teams under funding pressure tend to postpone them until the first duplicate order arrives. unknown , tell the model "pending, do not retry", and resolve the state by reading the downstream system. SifrVentures https://sifrventures.com builds dedicated engineering teams for tech companies. Based in Berlin. Learn how we work https://sifrventures.com/how-we-work | Read more on our blog https://sifrventures.com/blog