# Safe APIs for AI Agents: Writes You Can Trace

An AI agent does not make an API dangerous by itself. An API becomes dangerous when it offers broad verbs, ambiguous outcomes, and errors that force the caller to guess. Human operators compensate with context, hesitation, and a quick message in chat. An agent compensates with retries and another tool call. That difference turns a harmless timeout into two refunds, two deployments, or a deleted record that nobody meant to touch.

Safe APIs for AI agents make the permitted action small, make repeated writes harmless, and leave a trail that a person can follow later. This is API design work, not prompt work. A prompt can tell an agent to be careful; the endpoint must still reject an action that falls outside its contract.

I have seen teams put approval screens in front of a loose administrative endpoint and call that control. It is not enough. If the approved call means "change anything in this account," the approval asks a human to inspect a bundle of hidden consequences under time pressure. Put the precision in the API first. Approval then has something intelligible to approve.

## Broad verbs make agents guess

An agent should call an operation whose name, inputs, and side effects fit in one sentence. Broad endpoints force it to infer business rules from loose fields, old examples, or an error that says only "bad request." That is where unsafe improvisation starts.

Consider an endpoint such as `POST /admin/execute` with a payload containing `action` and arbitrary JSON. A human-written client may use only five actions today, but the endpoint presents every present and future action to any caller that receives access. The server cannot communicate a useful permission boundary, and an approver cannot tell what the agent will do without reading the payload like source code.

Replace it with operations that name a state transition:

- `POST /projects/{project_id}/deployments` creates one deployment from a specified revision.
- `POST /invoices/{invoice_id}/refunds` creates a refund with an explicit amount and reason.
- `POST /users/{user_id}/access-revocations` removes access for one named user.
- `POST /exports` starts a defined export with a declared data category.

These operations can still carry risk. The point is that each one gives the server somewhere to enforce rules: valid transitions, amount limits, target ownership, required approvals, and a reason field where it belongs.

Do not confuse a generic CRUD interface with a usable agent interface. `PATCH /customers/{id}` invites a caller to change any writable field. If changing `billing_email` is routine but changing `tax_status` starts a compliance process, those changes do not belong behind the same casual patch. Create a focused operation for the consequential transition, and make its input model reflect the decision.

A narrow operation also improves recovery. When an agent says "the deployment request timed out," an operator can search for one deployment creation. When it says "the admin command timed out," the operator first has to discover what command it assembled.

### Put preconditions in the request

Writes should state the condition under which they make sense. A request that approves an expense can include the expected review state. A request that updates a document can include the version it read. If the state changed, the server should refuse the write rather than quietly apply it to a different reality.

HTTP already gives you useful mechanics. RFC 9110 defines conditional requests through headers such as `If-Match`; a server can reject a stale entity tag with `412 Precondition Failed`. You may also expose an `expected_version` field when that suits your API better. The choice matters less than the discipline: the client must name the version or state it intends to alter.

Do not accept a client field like `force: true` as an escape hatch for every conflict. That field routinely becomes a way for agents to bulldoze the exact safety check you added. Reserve an override for a separate operation, a different authorization level, and a visible audit record.

## A write needs an identity separate from the HTTP attempt

Every externally visible write should have a client-supplied idempotency identifier. The server uses it to recognize that several delivery attempts express the same intended action.

A request ID and an idempotency identifier solve different failures. A gateway or server often creates a request ID for each HTTP attempt. If the network drops after the server commits the write but before the response reaches the caller, the retry receives a new request ID. The idempotency identifier must remain the same, because the intended write has not changed.

The usual failure sequence looks like this:

1. The agent submits a request to create a payout.
2. Your server stores the payout and calls an upstream provider.
3. The connection fails before the agent receives the success response.
4. The agent sees an unknown outcome and retries.
5. Your server creates another payout because it sees a fresh HTTP request.

A retry policy did not cause the defect. The API did by treating delivery as intent.

Use a header or request field that the client creates before its first attempt and preserves until it gets a terminal answer. HTTP header names commonly use `Idempotency-Key`, although the identifier does not need to be a secret. A random UUID works well. Do not derive it from a timestamp alone, and do not use an identifier that can collide across unrelated writes.

### Store the request fingerprint and the result

The server must bind an idempotency identifier to more than a status flag. Store the caller identity, target route, a canonical fingerprint of the semantically relevant request body, and the complete result needed for replay. When the same caller retries with the same identifier and same fingerprint, return the original response. When the body differs, reject the request with a conflict.

The IETF draft "The Idempotency-Key HTTP Header Field" describes this header as a way for clients to make non-idempotent HTTP methods fault tolerant. Its warning about uniqueness matters: the client should not reuse a value for a different request. I would go one step further in implementation. Enforce that warning server-side, because agents retry, restart, and occasionally reuse state that a human client would have discarded.

A compact contract can look like this:

```http
POST /v1/projects/prj_48/deployments
Idempotency-Key: 8c8d77c1-4ef9-4fae-b0ba-5480f686ce4c
Content-Type: application/json

{
  "revision": "a1b2c3d4",
  "environment": "staging",
  "expected_project_version": 17
}
```

On the first accepted call, return a resource and both identifiers:

```json
{
  "request_id": "req_01J8X7QK3JZ6",
  "deployment": {
    "id": "dep_01J8X7R5G2",
    "state": "queued",
    "revision": "a1b2c3d4",
    "environment": "staging"
  }
}
```

If the agent repeats the identical request after a timeout, return the same `dep_01J8X7R5G2`, not a second deployment. If it changes `environment` to `production` while retaining the identifier, return a conflict that makes the repair obvious:

```json
{
  "error": {
    "code": "idempotency_payload_mismatch",
    "message": "This idempotency identifier belongs to a deployment request with different parameters.",
    "request_id": "req_01J8X84S9P2V"
  }
}
```

Keep idempotency records at least as long as realistic client retries and job recovery can occur. A very short retention period creates a delayed duplicate that looks intermittent in production. If storage pressure forces expiry, document the window plainly and make consumers choose retry behavior that respects it.

## Retrying only makes sense when the outcome is known enough

An agent should retry transport failures and selected transient responses, but it should never invent a new action to escape uncertainty. Your response classes need to make that choice possible.

RFC 9110 defines `429 Too Many Requests` and permits `Retry-After`; honor that mechanism if you send it. A caller can wait the stated duration, preserve its idempotency identifier, and submit the same request. For a temporary server failure, return a 5xx response with a request ID and state whether the server accepted the operation. Do not use a vague 500 for a validation failure or an authorization refusal. It teaches clients the wrong retry behavior.

For asynchronous writes, acceptance and completion are separate facts. A `202 Accepted` response should return an operation resource that names the work and its state. The agent can query that resource after a timeout instead of re-submitting a side effect.

```json
{
  "request_id": "req_01J8X9FW7GH2",
  "operation": {
    "id": "op_01J8X9FTVX",
    "state": "running",
    "status_url": "/v1/operations/op_01J8X9FTVX"
  }
}
```

The status resource needs more than `running` and `failed`. Include a terminal state, a result reference when successful, and a public failure code when the worker cannot complete the job. A deployment that failed health checks, for example, should not look like an API transport failure. The agent needs to report or repair a deployment failure; it should retry a connection failure only when the server never accepted the request.

Avoid automatic retries for actions that send email, charge money, rotate credentials, or invoke an outside system unless your server owns deduplication all the way to the effect. Idempotency in your database does not prevent two emails if a worker crashes after the email provider accepts the message and before your worker records completion. Use an outbox record with a stable provider-side deduplication reference where the provider supports it. If the outside system cannot deduplicate, make the operation observable and require a human decision after an unknown outcome.

## Errors must tell the caller how to repair the request

Useful error messages describe the failed contract, not the server's embarrassment. An agent can work with a precise error. It cannot work safely with an HTML error page, a stack trace, or "invalid input" after a request with ten fields.

Return a consistent JSON envelope for every expected failure. Give it a stable `code` for programs, a concise `message` for logs and humans, a request ID, and field-level details when the fields are safe to expose. RFC 9457, "Problem Details for HTTP APIs," provides a standard shape through fields such as `type`, `title`, `status`, `detail`, and `instance`. You do not need to adopt every field to learn its central lesson: errors are part of the API contract, not incidental prose.

This response tells an agent exactly what to change:

```json
{
  "error": {
    "code": "invalid_state_transition",
    "message": "A refund can be created only for a paid invoice.",
    "request_id": "req_01J8XAS2D8M4",
    "details": {
      "invoice_id": "inv_204",
      "current_state": "draft",
      "allowed_states": ["paid", "partially_paid"]
    }
  }
}
```

This response causes guessing:

```json
{
  "error": "Request failed"
}
```

The second one sends an agent back to documentation, source code, or exploratory calls. Exploratory calls against a write API are how a small defect turns into a noisy incident.

Do not place secrets in errors. Do not echo authorization headers, access tokens, signed URLs, raw database queries, or an upstream service response that may contain another customer's data. A common bad pattern is wrapping every caught exception and returning its message to the caller. That makes debugging easy for a day and creates a disclosure channel for years.

Separate invalid input from insufficient authority. `422 Unprocessable Content` can describe a well-formed payload that violates a business rule. `403 Forbidden` should say the requested operation requires a permission or approval, without revealing resources the caller cannot inspect. `404 Not Found` may make sense when you intentionally hide resource existence. Pick the semantics, document them, and apply them consistently.

A good error also says when retrying is pointless. `invalid_state_transition`, `idempotency_payload_mismatch`, and `approval_required` should stop blind retries. `rate_limited` with a retry delay and `upstream_temporarily_unavailable` can invite a controlled retry. That distinction saves more damage than a clever agent prompt.

## Request identifiers turn a disputed action into an investigation

Give every inbound request a request ID, return it in the response body or header, and carry it through every internal call, queue message, worker job, and outbound provider call. When an agent says it did not receive a response, you need to answer two separate questions: did your API accept the action, and what did each component do afterward?

Generate the request ID at the trust boundary if the caller does not provide one. You can accept a caller correlation ID for its own bookkeeping, but do not let an untrusted caller overwrite your server-issued identifier. Keep both when useful. The server identifier anchors your logs; the caller identifier connects a sequence of agent decisions.

Log structured events rather than assembling prose lines that operators later parse with regular expressions. At minimum, record the request ID, authenticated principal, operation name, target resource, idempotency identifier if present, authorization decision, result status, and references to created resources. Redact request fields by schema, not by a last-minute string filter. A field named `token` is easy to redact. A credential embedded in arbitrary text is not.

The trace must preserve ordering without pretending it proves more than it does. A request ID can show that your API accepted a job and that a worker submitted a provider call. It cannot prove a person intended the action unless your system records that decision separately. Keep the distinction sharp:

- A correlation record connects events that belong to one request.
- An audit record says who or what authorized an action and what the system did.
- An idempotency record prevents a duplicate logical write.

Teams often collapse these into one database row. Then the row has to serve retries, debugging, compliance review, and user-facing history, and it does none of them cleanly. You can store related references together, but preserve the separate meanings in the data model.

For higher-risk actions, record the normalized request, the authorization context, the policy or approval outcome, and a result digest in an append-only audit stream. Protect that stream from the normal application account. Otherwise a compromised service can rewrite the history that would expose it.

Sallyport takes a useful approach for agent actions: it records agent sessions and individual calls from one write-blind encrypted, hash-chained audit log, and `sp audit verify` checks the chain offline without a vault key. Your API still needs its own records, because a gateway can show that it dispatched a call while only your service can show the state transition it committed.

## Authentication scope does not repair an unsafe operation

Short-lived credentials and narrow scopes reduce blast radius, but they do not make a broad endpoint safe. A token limited to one project can still destroy every deployment, export all permitted data, or trigger every available administrative action in that project.

Tie authorization to operation and target. A caller allowed to create a deployment should not automatically be allowed to promote one to production. A caller allowed to revoke a user's access should not receive permission to change that user's billing profile because both happen to sit under `/users/{id}`.

Keep credential material out of the agent whenever you can. An agent that receives a bearer token can copy it into a transcript, a debug file, a shell history, or an external service call. Instead, put credential use behind a local action gateway or a server-side broker that selects the credential for an approved operation. The agent submits intent and parameters; the trusted component injects the secret only when it makes the outbound call.

This design does not remove the need to validate parameters. If an agent can say `url: https://anything.example`, a credential-injecting HTTP helper can become a secret exfiltration tool. Bind credentials to named upstreams and methods. Validate hosts after redirects as well as before them. For SSH, bind a credential to known hosts and a constrained command interface where possible, rather than offering arbitrary remote shell access.

Human approval has a place, but it should cover a small action with a visible target and consequence. Per-session approval answers "may this agent process act at all?" Per-call approval answers "may it perform this particular sensitive action now?" Neither one rescues an endpoint whose payload can mean anything.

## Concurrency needs an explicit loser

Idempotency stops duplicate delivery of one intention. It does not solve two different intentions that race each other. If two agents read an invoice in `paid` state and both submit a full refund with different idempotency identifiers, your server must decide which request wins.

Use a transactional state transition where your storage system supports it. The update should include the state it expects, and the server should report the conflict when another writer changed it first. A version field, entity tag, or conditional update gives the API a way to reject stale intent instead of applying it after the facts changed.

For example, model a refund as an operation against remaining refundable balance, not as a blind command that trusts a client-supplied amount. In one transaction, check the current paid amount, subtract prior refunds, validate the requested amount, reserve the new refund, and create the refund record. A separate asynchronous worker can call the payment provider after that reservation exists. If the worker retries, it resumes the same refund record rather than creating a new one.

Do not tell agents to "check first, then act" as your only concurrency control. A preflight `GET` helps an agent form a useful request, but another caller can change state between the read and write. The write endpoint owns correctness because it sees the actual state at commit time.

Design cancellation with the same care. `DELETE /operations/{id}` should not promise that an external action never occurred. It should return the actual cancellation state: cancellation requested, cancelled before dispatch, completed before cancellation, or cancellation impossible after dispatch. Agents and humans both need language that reflects the boundary between your system and the outside provider.

## Test unknown outcomes before agents find them in production

A test suite that checks only a 200 response trains everyone to ignore the hardest part of action APIs. Put failure cases in contract tests and run them against a real service boundary, not only a mocked handler.

For every write operation, test a sequence where the server commits the effect and the client loses the response. Submit the same idempotency identifier again and assert that the server returns the original resource. Then submit that identifier with a changed body and assert that the server returns a conflict without creating another resource.

Test concurrent requests with different idempotency identifiers against the same state transition. Assert that one succeeds and the other receives a specific stale-state or business-rule error. If both succeed in a test database because each test runs alone, you have not tested the property that matters.

Exercise the error contract as data. Assert status codes, stable error codes, field names, and the presence of a request ID. Do not snapshot only the English message. You will improve wording over time; clients should branch on `code`, not prose.

Finally, run an operator drill. Pick one completed action, one rejected action, one timeout with a successful server-side commit, and one asynchronous failure. Give an engineer only the request IDs and ask them to reconstruct what happened. If they need to grep across unrelated logs, inspect an agent transcript, and guess which retry created which record, fix the instrumentation before you allow unattended writes.

The first action to repair is usually the broadest write endpoint. Split it into named transitions, require an idempotency identifier, and make the response identify the resulting resource. Once that contract exists, agents can act quickly without treating every network hiccup as permission to try something different.
