AI agent retries without duplicate writes
The response disappeared. Did the write happen?
Your agent updates a support ticket and loses the response. It tries again. If each attempt reaches the API, the ticket can receive the same update twice, trigger another notification or repeat a related workflow. The difficult case is a timeout: a missing response does not tell you whether the API applied the write.
An API key allows both attempts. A rate limit may still allow the duplicate within its window. Logs help you investigate but cannot reliably stop two requests already in flight. The retry needs to carry the identity of the original intended operation. A new random key on each attempt describes a new operation, so it does not provide this protection.
Grant a run and name the write
Register the PATCH route on your app. On your backend, grant a scoped run with the actions this session needs. The app key and run token go to the agent. The admin key stays on the backend.
curl -X POST https://gate.odilelabs.com/api/projects/PROJECT/runs \
-H "Authorization: Bearer $ADMIN" -H 'content-type: application/json' \
-d '{"app":"APP_ID","scope":{"customer":"A"},"ttl_seconds":600,
"actions":[{"rule":"PATCH /customers/{customer}/tickets/:id","max":2}]}'
Every write in this run needs an Idempotency-Key. Gate forwards that header upstream too. Use a different key for a genuinely different operation, and retain the original key with the job so a retry cannot silently become another write.
curl -X PATCH https://gate.odilelabs.com/g/PROJECT/customers/A/tickets/1 \
-H "Authorization: Bearer $APP_KEY" -H "x-odile-run: $TOKEN" \
-H 'Idempotency-Key: ticket-1-priority' -H 'content-type: application/json' \
-d '{"priority":"high"}'
Make the result part of your control flow
The client generates a key when you omit one and returns it with the result. If your process might lose that result, create and save the key before calling. The SDK does not retry writes by itself. An explicit retry must reuse the key and request.
import { Gate } from 'https://gate.odilelabs.com/sdk/gate.mjs';
const gate = new Gate({ gateway, appKey, runToken: token });
const result = await gate.request('PATCH', '/customers/A/tickets/1', {
body: { priority: 'high' }, idempotencyKey: 'ticket-1-priority'
});
if (result.outcome === 'already_executed') console.log(result.data.outcome_status);
if (result.outcome === 'outcome_unknown') console.log('Check the API; do not retry.');
Recover a lost answer instead of guessing
Grant the write with a check: the GET on your API that finds a write by its key, for example {"rule":"POST /customers/{customer}/bookings","check":"GET /customers/{customer}/bookings?idempotency_key={key}","idempotent":true} (plus "GET /customers/{customer}/bookings" as a read action). After outcome_unknown, the agent sends POST /g/PROJECT/_gate/recover with its run token and the same Idempotency-Key. Gate fills in the key, asks your API, and answers executed (a retry then gets already_executed), not_executed with safe_to_retry: true (resend once with the same key; the run's limit is given back), or needs_review when the answer is not clear. not_executed needs "idempotent": true on the action, your word that the API dedupes on Idempotency-Key, since a timed-out write can land after the check; otherwise "not found" is needs_review. The check must be a read the run could make itself (app allow-list, a GET action of the run, user scope). A clear answer is a 404, a JSON list (or Stripe-style {"data":[...]}), or one object with an id.
const r = await gate.recover('booking-1');
if (r.data.outcome === 'not_executed') await gate.request('POST', '/customers/A/bookings', { body, idempotencyKey: 'booking-1' });
if (r.data.outcome === 'needs_review') notifyAPerson(r.data);
After a completed write, repeating its key returns 409 already_executed with the original status. While the request is in flight, or after a timeout or upstream 5xx, Gate returns outcome_unknown for that key and does not send it again. Check the API's state before deciding whether a new operation is needed. Changing keys to get past that answer defeats the protection.
Keep the promise narrow
Gate prevents another send through the same run and key. This is not a guarantee that your API performs every operation exactly once. Your API should honour the forwarded key; see Stripe's idempotent request documentation for that API's own semantics. Gate checks method, path and query, not body contents. Keep ownership checks and row-level security. Only calls through Gate are protected.
Try it
Try it: Demo Desk returns writes: 1 after the repeated PATCH, and in step 8 it books, loses the answer, and Gate finds the booking instead of making a second one.
curl -s https://gate.odilelabs.com/actions-quickstart.sh | sh