Agent Approval Protocol
What an adapter calls on behalf of a running agent. Create an instance and get its credential, ask for approval of a tool call, and wait for the answer.
The Agent Approval Protocol (AAP) is the open protocol between an agent runtime and an approval provider. An adapter sits between the agent and its tools. When the agent calls a tool that needs approval, the adapter holds the call, describes it to the provider, and waits. The provider collects a decision from a person, or from the organization's approval pipeline, and hands it back. The adapter then runs the call or fails it.
The protocol carries decisions only. It never executes a tool or forwards its result. Approving or denying is the whole job, which keeps the surface to a handful of calls that any runtime can implement and any provider can serve. withHuman is one provider; an adapter written against this page works with any other.
A typical exchange:
- The adapter obtains a credential, either from
withhuman agent install <runtime>on a developer machine or by creating an instance with a provisioner token. - For a tool call, the adapter creates an approval request describing the tool and its arguments. The pipeline may decide on the spot; otherwise the request is pending.
- It retrieves the request with a
wait, repeating until the status isapproved,denied,expired, orcancelled, and acts on it. - If the adapter stops waiting first (its runtime was interrupted or timed out), it cancels the request so reviewers stop seeing it.
Create an agent instance
/api/aap/v1/instancesBearer tokenCreates a new instance of the agent the provisioner token belongs to and returns it together with its agent credential. Use this from a fleet or platform that starts agent replicas itself, so each replica gets a credential of its own. The bearer token must be a provisioner token (whp_); an agent credential is rejected. Requires an Idempotency-Key header scoped to the provisioner: repeating the same submission with the same key within seven days answers 200 with the original instance and credential, without creating another instance, renewing the credential or verifying the receiver again. Reusing a key with different input answers 409 with code idempotency_conflict, a key whose attempt is still running answers 409 with code idempotency_in_progress, and replaying a key whose instance was deleted answers 409 with code instance_deleted. Pass delivery to use the Agent Approval Protocol's asynchronous mode: before the instance is created, withHuman sends a signed webhook.verification message to the receiver, which must answer 200 with the same challenge within ten seconds, or the call fails with 422 and code webhook_verification_failed and no instance is created (the same key may be retried). A malformed or non-public receiver URL answers 400 with code invalid_webhook_url. Afterwards, every request of the instance that becomes approved, denied, expired or cancelled sends one signed request.resolved notification to the receiver, retried with backoff for up to seven days until it answers 2xx; notifications wait while the instance is disabled. The notification carries only IDs; read the decision with GET /api/aap/v1/requests/{id}. Both messages are signed with the Standard Webhooks scheme (webhook-id, webhook-timestamp and webhook-signature headers). Requires the deployment's secrets key; without it, delivery is refused with 501.
Request
Parameters
| Parameter | Type | Description |
|---|---|---|
Idempotency-Keyrequired | header · string | A key of your choosing that identifies this call, so a retry does not act twice. See Idempotency in the API overview. |
Request bodyoptional
A name for the instance, unique within the agent. Reviewers see it next to the agent's name.
Any JSON object to store with the instance, such as a region or pod name.
How long the credential stays valid, in seconds. A value above the provider's default is reduced to the default.
A webhook receiver for asynchronous mode. Install the registration ID and signing secret on the receiver before calling: the verification message names the registration so the receiver can pick the secret before it knows the instance ID.
Response
Response codes
| Status | Body | Description |
|---|---|---|
200 | AAPProvisionedInstance | The instance and credential an earlier submission with the same idempotency key created. |
201 | AAPProvisionedInstance | The new instance and its credential. Keep the credential's token; only a replay with the same provisioner token and idempotency key returns it again. |
400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
409 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
422 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
501 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
Response body200
An instance as the Agent Approval Protocol returns it to a provisioner. Credentials and the signing secret are never included.
Example
curl -X POST "$WITHHUMAN_URL/api/aap/v1/instances" \
-H "Authorization: Bearer $WITHHUMAN_TOKEN" \
-H "Idempotency-Key: 4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31" \
-H "Content-Type: application/json" \
-d '{
"instance_name": "ci-runner-07",
"instance_metadata": {
"region": "eu-west"
},
"ttl_seconds": 86400,
"delivery": {
"type": "webhook",
"url": "https://harness.example.com/aap/events",
"registration_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
"signing_secret": "string"
}
}'{
"instance": {
"id": "b29a43a7-1949-4b9f-9d61-b7ae66f85d31",
"name": "ci-runner-07"
},
"credential": {
"token": "whc_live_3f6b9c1d0e7a4b2c",
"expires_at": "2026-09-18T12:00:00Z"
}
}Retrieve an agent instance
/api/aap/v1/instances/{id}Bearer tokenReturns an instance of the provisioner token's agent with its receiver, if any. The signing secret and credentials are never returned. A deleted instance, or one of another agent, answers 404.
Request
Parameters
| Parameter | Type | Description |
|---|---|---|
idrequired | path · uuid |
Response
Response codes
| Status | Body | Description |
|---|---|---|
200 | AAPInstance | The instance |
401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
Response body200
Unique within the agent.
The verified receiver of an instance using asynchronous mode, returned when the instance is created. The signing secret is never returned.
Example
curl -X GET "$WITHHUMAN_URL/api/aap/v1/instances/{id}" \
-H "Authorization: Bearer $WITHHUMAN_TOKEN"{
"id": "b29a43a7-1949-4b9f-9d61-b7ae66f85d31",
"name": "ci-runner-07",
"delivery": {
"type": "webhook",
"url": "https://harness.example.com/aap/events",
"registration_id": "8246931c-4ce0-4c15-a94a-e9c078ad06e2"
}
}Update an agent instance
/api/aap/v1/instances/{id}Bearer tokenRenames an instance of the provisioner token's agent, replaces its receiver, or removes it. A delivery object replaces the whole configuration, signing secret included, and is verified first exactly as at creation: the old configuration and name stay in place until verification succeeds, and a failure answers 422 with code webhook_verification_failed. Queued notifications then go to the new receiver with the new secret, with unchanged IDs and bodies. delivery: null removes the receiver and ends outstanding deliveries and retries. Requires an Idempotency-Key header scoped to the provisioner and the instance: repeating the same submission answers the original response without applying it again. A deleted instance answers 404.
Request
Parameters
| Parameter | Type | Description |
|---|---|---|
Idempotency-Keyrequired | header · string | A key of your choosing that identifies this call, so a retry does not act twice. See Idempotency in the API overview. |
idrequired | path · uuid |
Request body
Omit to keep the receiver, pass a complete configuration to replace it, or null to remove it.
Response
Response codes
| Status | Body | Description |
|---|---|---|
200 | AAPInstance | The updated instance |
400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
409 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
422 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
501 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
Response body200
Unique within the agent.
The verified receiver of an instance using asynchronous mode, returned when the instance is created. The signing secret is never returned.
Example
curl -X PATCH "$WITHHUMAN_URL/api/aap/v1/instances/{id}" \
-H "Authorization: Bearer $WITHHUMAN_TOKEN" \
-H "Idempotency-Key: 4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31" \
-H "Content-Type: application/json" \
-d '{
"instance_name": "ci-runner-07",
"delivery": {
"type": "webhook",
"url": "https://harness.example.com/aap/events",
"registration_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
"signing_secret": "string"
}
}'{
"id": "b29a43a7-1949-4b9f-9d61-b7ae66f85d31",
"name": "ci-runner-07",
"delivery": {
"type": "webhook",
"url": "https://harness.example.com/aap/events",
"registration_id": "8246931c-4ce0-4c15-a94a-e9c078ad06e2"
}
}Delete an agent instance
/api/aap/v1/instances/{id}Bearer tokenRetires an instance of the provisioner token's agent: its credentials are revoked, its receiver is removed with every outstanding delivery, and each of its pending requests is cancelled without a notification. Requests that already reached an outcome are unchanged. The instance stays listed in the product, disabled, and cannot be enabled again. Deleting it again answers 204.
Request
Parameters
| Parameter | Type | Description |
|---|---|---|
idrequired | path · uuid |
Response
Response codes
| Status | Body | Description |
|---|---|---|
204 | The instance is deleted | |
401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
Example
curl -X DELETE "$WITHHUMAN_URL/api/aap/v1/instances/{id}" \
-H "Authorization: Bearer $WITHHUMAN_TOKEN"Create an approval request
Asks for approval to run a tool call. The organization's approval pipeline evaluates the request first and may approve or deny it on the spot, in which case the response already carries the decision. Otherwise the request goes to reviewers and comes back as pending; retrieve it with a wait to learn the outcome. A request nobody decides before its timeout becomes expired: the adapter does not run the call and tells the agent the approval timed out. An adapter that stops waiting should cancel the request so reviewers stop seeing it. Requires an Idempotency-Key header scoped to this operation and the authenticated instance. Reusing it with changed input returns 409.
Request
Parameters
| Parameter | Type | Description |
|---|---|---|
Idempotency-Keyrequired | header · string | A key of your choosing that identifies this call, so a retry does not act twice. See Idempotency in the API overview. |
Request body
The name under which the tool is defined: the name an MCP server advertises in tools/list, or the runtime's own name for a built-in tool such as Bash. Never the runtime's joined spelling such as mcp__stripe__issue_refund.
The alias of the MCP server that defines the tool, as the runtime configured it. Present only for tools served over MCP. A label the adapter observed, not a verified identity.
The exact arguments the tool will run with if approved. Reviewers see this as the description of the action, so it must be complete.
The agent's own explanation of why it wants to do this. Reviewers see it as a claim from the agent, separate from the arguments.
Where the call comes from, as observed by the adapter rather than stated by the agent: for example the runtime, session id, or working directory.
How long the request may wait for a decision, as a duration such as 30m or 24h. Between one second and seven days. Once it passes, the request expires.
Response
Response codes
| Status | Body | Description |
|---|---|---|
201 | AAPApprovalRequest | The request, either already decided by the pipeline or pending review |
400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
409 | The idempotency key was already used for a different request | |
429 | The organization has reached its limit of pending requests |
Response body201
One of pending, approved, denied, expired, cancelled
Immutable decision. An approved call must start before expires_at: 30 minutes after decided_at when the instance had webhook delivery at decision time, five minutes otherwise. Reads and retries never extend expires_at.
The creating instance used the approval. Present only once an approved request was consumed. An adapter that finds a consumption it did not make treats the approval as used.
Example
curl -X POST "$WITHHUMAN_URL/api/aap/v1/requests" \
-H "Authorization: Bearer $WITHHUMAN_TOKEN" \
-H "Idempotency-Key: 4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31" \
-H "Content-Type: application/json" \
-d '{
"tool": "issue_refund",
"server": "stripe",
"arguments": {
"amount": 4900,
"reason": "duplicate_charge"
},
"agent_reasoning": "Refunding the duplicate charge for [email protected].",
"context": {
"run_id": "4821",
"framework": "claude-code"
},
"timeout": "30m"
}'{
"id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
"tool": "issue_refund",
"arguments": {
"amount": 4900,
"reason": "duplicate_charge"
},
"timeout": "30m",
"status": "pending",
"created_at": "2026-09-17T12:00:00Z",
"deadline_at": "2026-09-17T12:30:00Z"
}{
"id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
"tool": "issue_refund",
"arguments": {
"amount": 4900,
"reason": "duplicate_charge"
},
"timeout": "30m",
"status": "approved",
"created_at": "2026-09-17T12:00:00Z",
"deadline_at": "2026-09-17T12:30:00Z",
"decision": {
"status": "approved",
"note": "Refund the duplicate charge.",
"decided_at": "2026-09-17T12:02:00Z",
"expires_at": "2026-09-17T12:07:00Z"
}
}Retrieve an approval request
Returns the request and, once it has been decided, its decision. Pass wait to hold the response until the request is decided or the wait runs out, whichever comes first. Only the instance that created the request may retrieve it. A request stops changing once it is approved, denied, expired, or cancelled.
Request
Parameters
| Parameter | Type | Description |
|---|---|---|
idrequired | path · uuid | The request's id. |
wait | query · string | How long to hold the response for a decision, as a duration such as |
Response
Response codes
| Status | Body | Description |
|---|---|---|
200 | AAPApprovalRequest | The request in its current state |
401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
Response body200
One of pending, approved, denied, expired, cancelled
Immutable decision. An approved call must start before expires_at: 30 minutes after decided_at when the instance had webhook delivery at decision time, five minutes otherwise. Reads and retries never extend expires_at.
The creating instance used the approval. Present only once an approved request was consumed. An adapter that finds a consumption it did not make treats the approval as used.
Example
curl -X GET "$WITHHUMAN_URL/api/aap/v1/requests/{id}?wait=30s" \
-H "Authorization: Bearer $WITHHUMAN_TOKEN"{
"id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
"tool": "issue_refund",
"arguments": {
"amount": 4900,
"reason": "duplicate_charge"
},
"timeout": "30m",
"status": "pending",
"created_at": "2026-09-17T12:00:00Z",
"deadline_at": "2026-09-17T12:30:00Z"
}{
"id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
"tool": "issue_refund",
"arguments": {
"amount": 4900,
"reason": "duplicate_charge"
},
"timeout": "30m",
"status": "approved",
"created_at": "2026-09-17T12:00:00Z",
"deadline_at": "2026-09-17T12:30:00Z",
"decision": {
"status": "approved",
"note": "Refund the duplicate charge.",
"decided_at": "2026-09-17T12:02:00Z",
"expires_at": "2026-09-17T12:07:00Z"
}
}Cancel an approval request
Withdraws a pending request. Use it when the runtime that asked stops waiting for the answer: it was interrupted, timed out locally, or is shutting down. Reviewers stop seeing the request, notifications stop, and anyone holding it is released. Only the instance that created the request may cancel it; another instance of the same agent gets a 403. A cancelled request is final and answers cancelled with a protocol decision, so a long poll in flight returns at once. Cancelling again returns the same cancelled request. A request that was already approved, denied, or expired is unchanged and answers 409 with code already_terminal. This is an optional capability of the Agent Approval Protocol: adapters must treat a 404 or 405 from a provider that does not offer it as nothing to do.
Request
Parameters
| Parameter | Type | Description |
|---|---|---|
idrequired | path · uuid | The request's id. |
Response
Response codes
| Status | Body | Description |
|---|---|---|
200 | AAPApprovalRequest | The request, now cancelled |
401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
409 | The request was already decided or expired (code |
Response body200
One of pending, approved, denied, expired, cancelled
Immutable decision. An approved call must start before expires_at: 30 minutes after decided_at when the instance had webhook delivery at decision time, five minutes otherwise. Reads and retries never extend expires_at.
The creating instance used the approval. Present only once an approved request was consumed. An adapter that finds a consumption it did not make treats the approval as used.
Example
curl -X DELETE "$WITHHUMAN_URL/api/aap/v1/requests/{id}" \
-H "Authorization: Bearer $WITHHUMAN_TOKEN"{
"id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
"tool": "issue_refund",
"arguments": {
"amount": 4900,
"reason": "duplicate_charge"
},
"timeout": "30m",
"status": "cancelled",
"created_at": "2026-09-17T12:00:00Z",
"deadline_at": "2026-09-17T12:30:00Z",
"decision": {
"status": "cancelled",
"note": "The agent stopped waiting.",
"decided_at": "2026-09-17T12:02:00Z"
}
}Consume an approval
Marks an approved request as used, right before the adapter runs the call. Each approval runs one call: only one consume succeeds, so a second attempt to run the same call is refused. Call it after checking decision.expires_at, and do not start the call until it succeeds. Only the instance that created the request may consume it; another instance gets a 403. Requires an Idempotency-Key header that identifies this execution attempt: repeating the key returns the request as it is now, even after expires_at. The request takes no body; an empty body or {} is accepted. Answers 409 with code already_consumed when another key already consumed it, not_approved when the request is not approved, and approval_expired once expires_at has passed.
Request
Parameters
| Parameter | Type | Description |
|---|---|---|
idrequired | path · uuid | The request's id. |
Idempotency-Keyrequired | header · string | A key of your choosing that identifies this call, so a retry does not act twice. See Idempotency in the API overview. |
Request bodyoptional
object (application/json)
Response
Response codes
| Status | Body | Description |
|---|---|---|
200 | AAPApprovalRequest | The request, now consumed |
400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
409 | The approval was already used (code |
Response body200
One of pending, approved, denied, expired, cancelled
Immutable decision. An approved call must start before expires_at: 30 minutes after decided_at when the instance had webhook delivery at decision time, five minutes otherwise. Reads and retries never extend expires_at.
The creating instance used the approval. Present only once an approved request was consumed. An adapter that finds a consumption it did not make treats the approval as used.
Example
curl -X POST "$WITHHUMAN_URL/api/aap/v1/requests/{id}/consume" \
-H "Authorization: Bearer $WITHHUMAN_TOKEN" \
-H "Idempotency-Key: 4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31" \
-H "Content-Type: application/json" \
-d '{}'{
"id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
"tool": "issue_refund",
"arguments": {
"amount": 4900,
"reason": "duplicate_charge"
},
"timeout": "30m",
"status": "approved",
"created_at": "2026-09-17T12:00:00Z",
"deadline_at": "2026-09-17T12:30:00Z",
"decision": {
"status": "approved",
"note": "Refund the duplicate charge.",
"decided_at": "2026-09-17T12:02:00Z",
"expires_at": "2026-09-17T12:07:00Z"
},
"consumption": {
"consumed_at": "2026-09-17T12:02:04Z"
}
}Report an approved call's outcome
Records whether the approved call succeeded, after the adapter consumed the approval and ran it. The outcome is for the record only: it never changes the request's status or decision. Report it once. Repeating the same body returns the same request; a different outcome answers 409 with code outcome_conflict. A request that was not consumed answers 409 with code not_consumed. Only the instance that created the request may report its outcome; another instance gets a 403.
Request
Parameters
| Parameter | Type | Description |
|---|---|---|
idrequired | path · uuid | The request's id. |
Request body
A short note about the result, such as an error message or a reference.
Response
Response codes
| Status | Body | Description |
|---|---|---|
200 | AAPApprovalRequest | The request with the outcome |
400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
409 | The approval was not consumed (code |
Response body200
One of pending, approved, denied, expired, cancelled
Immutable decision. An approved call must start before expires_at: 30 minutes after decided_at when the instance had webhook delivery at decision time, five minutes otherwise. Reads and retries never extend expires_at.
The creating instance used the approval. Present only once an approved request was consumed. An adapter that finds a consumption it did not make treats the approval as used.
Example
curl -X POST "$WITHHUMAN_URL/api/aap/v1/requests/{id}/outcome" \
-H "Authorization: Bearer $WITHHUMAN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"status": "succeeded",
"note": "Refund issued"
}'{
"id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
"tool": "issue_refund",
"arguments": {
"amount": 4900,
"reason": "duplicate_charge"
},
"timeout": "30m",
"status": "approved",
"created_at": "2026-09-17T12:00:00Z",
"deadline_at": "2026-09-17T12:30:00Z",
"decision": {
"status": "approved",
"note": "Refund the duplicate charge.",
"decided_at": "2026-09-17T12:02:00Z",
"expires_at": "2026-09-17T12:07:00Z"
},
"consumption": {
"consumed_at": "2026-09-17T12:02:04Z",
"outcome": {
"status": "succeeded",
"note": "Refund issued",
"reported_at": "2026-09-17T12:02:06Z"
}
}
}