{"slug": "your-ai-agent-will-retry-your-booking-system-will-not-notice", "title": "Your AI Agent Will Retry. Your Booking System Will Not Notice.", "summary": "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.", "body_md": "*Letting an agent write to a legacy system of record takes an intent log, not a better prompt.*\n\nThe 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.\n\nEvery 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.\n\nThe 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.\n\nThe 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.\n\nStripe 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.\n\nThe 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.\n\nThe 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.\n\nWe 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.\n\nThe 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.\n\n```\nCREATE TABLE tool_intents (\n  intent_key   text PRIMARY KEY,\n  run_id       uuid NOT NULL,\n  tool         text NOT NULL,\n  args         jsonb NOT NULL,\n  state        text NOT NULL\n               CHECK (state IN ('pending','sent','confirmed','rejected','unknown')),\n  external_ref text,\n  created_at   timestamptz NOT NULL DEFAULT now(),\n  updated_at   timestamptz NOT NULL DEFAULT now()\n);\n```\n\nThe key is computed from business intent, not from the request:\n\n``` js\nconst parsed = BookingArgs.parse(rawArgs);        // zod: \"two\" becomes 2, dates become ISO\nconst intentKey = sha256([run.id, 'create_booking', stableStringify(parsed)].join('|'));\n\nconst res = await db.query(\n  `INSERT INTO tool_intents (intent_key, run_id, tool, args, state)\n   VALUES ($1, $2, 'create_booking', $3, 'pending')\n   ON CONFLICT (intent_key) DO NOTHING\n   RETURNING intent_key`,\n  [intentKey, run.id, parsed],\n);\n\nif (res.rowCount === 0) return loadPriorResult(intentKey); // same intent, same answer\n```\n\nSchema 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.\n\nThe 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.\n\nWhen 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.\n\nMeanwhile 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.\n\nGive 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.\n\nThis 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.\n\nNone 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.\n\n`unknown`, tell the model \"pending, do not retry\", and resolve the state by reading the downstream system.\n[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)", "url": "https://wpnews.pro/news/your-ai-agent-will-retry-your-booking-system-will-not-notice", "canonical_source": "https://dev.to/hassan_4e2f0901edda/your-ai-agent-will-retry-your-booking-system-will-not-notice-3e0", "published_at": "2026-10-29 06:08:38+00:00", "updated_at": "2026-10-29 06:13:48.562211+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "ai-infrastructure", "mlops"], "entities": ["Stripe", "LangGraph", "Temporal", "Postgres"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/your-ai-agent-will-retry-your-booking-system-will-not-notice", "markdown": "https://wpnews.pro/news/your-ai-agent-will-retry-your-booking-system-will-not-notice.md", "text": "https://wpnews.pro/news/your-ai-agent-will-retry-your-booking-system-will-not-notice.txt", "jsonld": "https://wpnews.pro/news/your-ai-agent-will-retry-your-booking-system-will-not-notice.jsonld"}}