What are human-in-the-loop agent actions?

Human-in-the-loop agent actions are writes that an AI agent proposes and a person approves before they run. The person decides one thing: whether this exact call should happen. The action layer enforces the rest regardless: the call runs at most once, only on the state the person saw, with the user’s rights, and leaves a record.

It is layer 3 of the enterprise AI platform architecture, built last, once identity and the decision log exist; the MCP write-access checklist says what to require of a vendor.

The action layer at a glance

Step or guarantee The rule Where it is enforced How to test it
Propose A manifest tool, schema-valid arguments The server, against the pinned manifest Call an unlisted tool: refused
Approve A person approves the rendered parameters A hash-bound approval only the approval service writes Change a parameter after approval: refused
Execute, once One approval, one key, at most one execution, before expiry The execution record’s primary key Send the approved call twice, then concurrently: one execution
State at execution The record is as the approver saw it If-Match, or a version in the WHERE clause Edit the record after approval: no write
The log Every step appended and exported Insert-only grants, S3 Object Lock in compliance mode; I would add a hash chain Update a row as the server’s role: denied
Identity The user’s rights, never more Token exchange per call; IRSA for AWS access A user without refund rights asks: the target refuses

The flow: propose, approve, execute

Reads run without approval. Each write is a record moving through states, not a call the agent makes. The agent only creates proposals; the approval service alone approves or rejects them, and an unused approval expires; the action server alone moves one to running, then succeeded or failed.

A proposal holds the tool and manifest version, the target record, the validated arguments, the record’s expected state, the user from the verified token and the agent run. The approval binds to a hash of it over canonical JSON (RFC 8785), so two encodings of the same arguments match.

The person sees what the server renders from that record, never the agent’s text: target, parameters, and the identity the call will use. I would also show current values beside new ones, so the approver judges a change rather than a form; the values read become the precondition.

Cost: one target read per proposal, and a screen to build. Test: have the agent describe a refund of 10 while proposing 100; the screen says 100.

One MCP action server, versioned tool manifests

Called directly, business APIs leave each integration to decide what agents may do and log, with nothing reviewable in one place. So every agent action goes through one MCP action server. It holds the means to reach business systems, not the agent, and lists only the tools its manifests define.

A manifest is your description of one tool, reviewed like code: name and version, input and output JSON Schemas (MCP’s inputSchema and outputSchema), read or write, the target and token audience, the expected-state fields, whether the target takes an idempotency key, whether approval is required. Classification belongs here, because the MCP tools page says clients MUST treat tool annotations as untrusted unless from trusted servers.

A proposal names its manifest version, so a decision is read against the manifest in force when it was approved. If a newer version ships before execution, I would refuse and let the agent re-propose. Run the golden set on every manifest change.

Cost: a review per new tool or field; re-approval of pending writes at each release. Test: publish a version between approval and execution; the write is refused.

Idempotency: the approval is the key

The approval ID is the idempotency key: one approval, at most one execution. The JSON-RPC ID cannot serve, because a request lost with a broken stream is re-issued with a new one (changelog; see what the 2026-07-28 revision changes).

A minimal sketch, not the code of any production system; on PostgreSQL 18 at Read Committed, the default, it behaves as described:

CREATE TABLE approvals (
  approval_id uuid PRIMARY KEY,
  params_hash bytea NOT NULL,
  expires_at  timestamptz NOT NULL
);

CREATE TABLE executions (
  approval_id uuid PRIMARY KEY REFERENCES approvals,
  result      jsonb
);

INSERT INTO executions (approval_id)
SELECT approval_id FROM approvals
WHERE approval_id = $1 AND params_hash = $2 AND expires_at > now()
ON CONFLICT (approval_id) DO NOTHING
RETURNING approval_id;

One row back: run the write. Otherwise, check in this order:

  1. A hash unlike the approval’s: refuse, the 422 of the IETF Idempotency-Key draft (expired in April 2026: a design reference, not a standard).
  2. A stored result: return it, success or error, as the draft suggests.
  3. A NULL result: an attempt in flight, or an unknown outcome after a timeout. Answer “in progress”, the draft’s 409, and run nothing.
  4. No record: the approval expired or never existed.

A concurrent claim waits for the first transaction and, once it commits, gets no row. Under Repeatable Read or Serializable it fails with a serialization error instead, and retrying the transaction gets no row. Only one claim succeeds.

Commit the claim before calling the target: if its transaction rolls back after the call, a retry claims again and the write runs twice. Pass the approval ID downstream when the target takes a key.

Keep each execution record at least until its approval expires, or a purge reopens the gap; I would keep it with the log for late retries. Cost: a row and a round trip per write. Test: claim twice, then from two sessions at once; one row comes back.

Preconditions: check the state the approver saw

A precondition is the state the approval assumed: the version read at proposal time and the manifest’s business conditions (“the invoice is unpaid”). Someone can pay the invoice before execution, so check it in the same operation as the write.

Over HTTP, send If-Match, the usual guard against lost updates; the server must not apply the method if the entity tag no longer matches, and may answer 412 (RFC 9110). On a database you own:

UPDATE invoices SET status = 'refunded', version = version + 1
WHERE id = $1 AND status = 'unpaid' AND version = $2;  -- 0 rows: the state changed

Under Read Committed, a concurrent update that commits first makes this statement re-check its WHERE clause on the new row version, so it updates nothing. A target with neither leaves read-then-write and a race window; record that in its manifest.

A failed precondition means re-propose, never force, because forcing turns a stale approval into a write nobody approved. After an unknown outcome, read first: a 412 can mean your first attempt landed, a case RFC 9110 lets a server answer with a success.

Cost: a version on every target; re-approvals on busy records. Test: edit the record after approval; nothing is written and the log says why.

The decision log: append-only and exported

The decision log records every proposal, approval, rejection, expiry and execution in the same transaction as the step: no entry, no step. An entry holds the step and IDs, manifest version, parameter hash, argument references, expected state, user (for executions, sub and act from the exchanged token), approver, a hash of the screen shown, and outcome.

Append-only means entries are never edited; corrections are new entries. Grants enforce it for the application: the server’s role gets INSERT and SELECT, and a role nobody logs in as owns the table. Owners and superusers keep their rights (privileges), so I would also chain entries, each storing a SHA-256 of the previous hash and its own content.

Recomputing the chain catches an edited or deleted row, not an owner who rewrites a row and every later hash, nor a deleted tail. The exported hash catches those up to its export; newer entries are only as safe as the database. Keep personal data out of the chained content, in a table you can erase, since redacting a chained entry breaks the chain (the checklist covers erasure).

Export each batch with its last hash to S3 Object Lock in compliance mode, where no user, root included, can overwrite or delete a locked object version, change its mode or shorten its retention, and only closing the AWS account deletes it early (S3 Object Lock).

Replay rebuilds a decision from the log; it never re-sends the write.

Cost: serial appends and an export job. Test: alter a row in a copy; the chain check fails there.

Identity: the call carries the user’s rights

The write carries a token issued for the target in exchange for the user’s (RFC 8693), so the target enforces the user’s rights. Under delegation, if your identity provider issues it, the act claim names the server; log sub and act from that token, never from the agent’s request.

For its own AWS access, such as the export, the server uses IAM roles for service accounts: temporary credentials per service account, isolated between pods only if instance metadata access is restricted. Layer 5 of the platform architecture details the exchange.

Cost: an identity provider and targets that support the exchange. Test: decode the token the target received; sub must be the user and, under delegation, act the server.

When a part fails, writes stop

The layer fails closed. Unless it can show that an action is approved, current and logged, nothing runs.

  • Approval service down: proposals wait and none runs. Pending writes need an owner; a timeout never approves.
  • Approval expired: the claim returns no row and the agent re-proposes. I would keep approvals short, because a precondition catches a changed record, not a stale decision.
  • Log store down: the claim shares a transaction with its entry, so it fails too. If the log fails after the call, reconcile the unrecorded result with the target.

Cost: an outage of the server or its database stops all writes. Test: revoke the server’s INSERT on the log, then approve a write; no execution record appears.

In practice

On the ENSO platform, I specified agentic actions through a single MCP action server with versioned tool manifests, human validation on every write, idempotency and precondition checks, and an append-only decision log that makes every AI decision replayable, with a WORM export to S3 Object Lock. The AI inherits the user’s permissions through OAuth2 Token Exchange (Keycloak, OIDC) and IRSA workload identities. The full architecture is in the ENSO case study, and the service on the AI agents governance page.

Trade-offs and limits

Human validation on every write limits throughput and is a poor fit for high-volume, low-risk automation. Relax it per tool, only where the manifest marks the risk as low, by a reviewed manifest change; keep the key, precondition and log, which cost no human time.

An action layer is another service to build and keep available. Its idempotency store grows with every write, and failed preconditions cost re-approvals, most on busy records.

Approval does not stop an approver who clicks without reading, nor a prompt injection, an excessive-agency trigger, that yields a plausible proposal: the person checks parameters, not intent. The user’s rights and preconditions still bound the damage.

The log is only as useful as the discipline around it: define retention, access and export before you need them.

Common questions

What does human-in-the-loop mean for an AI agent that writes to business systems?

The agent proposes a write; a person approves its exact parameters before it runs. Reads need no approval. The action server enforces the rest: the approval binds to a parameter hash, and the write runs once, on an unchanged record, with the user’s rights.

How do you make an AI agent’s write idempotent?

Use the approval ID as the idempotency key. Insert an execution record keyed by it, and commit, before calling the target. A retry finds the record and gets the stored result, or “in progress”, never a second write. Pass the key to the target when it accepts one.

How do you log AI agent actions for audit and replay?

Append every proposal, approval, rejection and execution, with manifest version and parameter hash, in the step’s own transaction. Grant the server insert only and export batches to S3 Object Lock in compliance mode; I would chain entries by hash, personal data kept outside. Replay rebuilds a decision without re-sending the write.

What happens when an approved agent action times out?

The outcome is unknown: the target may have applied the write. The record keeps no result, so retries get “in progress” and nothing re-runs it. Someone reads the target and records success or failure; after a failure the agent re-proposes, and whichever write lands second fails its precondition.

Sources