> ## Documentation Index
> Fetch the complete documentation index at: https://mezmo-9a59581a-promptless-aura-hitl-webhook-hmac.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Human-in-the-Loop Approval Gates

> Configure webhook and conversational approval gates for orchestration worker tool calls.

Human-in-the-loop (HITL) approval gates let an agent ask for permission before
running selected MCP tools. They compose in both single-agent and orchestration
mode. Use them for operations that need a human decision before execution, such
as production changes or destructive actions.

Current behavior:

* Gates compose in both single-agent mode and for orchestration workers. A
  single-agent run reports `scope.kind = "single"`; an orchestration worker
  reports `scope.kind = "worker"`.
* Webhook routing works for unattended approvals.
* Conversational routing works for attended approvals over an open SSE stream.
  The AURA CLI in HTTP mode is the first attended client.
* Matching tool calls are blocked until the configured route approves them.
* Human denials are returned to the model as normal tool feedback, so the agent
  can explain the denial without treating it as a transport failure.
* Timeouts, cancellation, and webhook channel failures still fail closed as tool
  errors.
* Conversational HITL requires `stream=true`; non-streaming requests are
  rejected because approval prompts are delivered over SSE.
* Approval lifecycle events emit on streaming responses. Webhook emits
  `aura.approval_requested` and `aura.approval_completed`; conversational also
  emits `aura.approval_pending` while the tool call is parked.

## Configure a webhook gate

Add a top-level `[hitl]` table and a required `[hitl.route]` table:

```toml theme={null}
[hitl]
require_approval = ["kubectl_*", "restart_*", "dangerous_*"]

[hitl.route]
mode = "webhook"
url = "https://approvals.example.com/aura"
timeout_secs = 300
```

`require_approval` is a list of glob patterns matched against MCP tool names.
When an agent calls a matching tool, Aura requests approval through the
configured route before the MCP tool runs.

A tool is gated if it matches **any** pattern in the list, so pattern order does
not affect whether a tool is gated. To leave a tool ungated, do not list a
pattern that matches it. When more than one pattern matches, the first in config
order is reported as `origin.matched_pattern` in the webhook payload and SSE
events; that is the only effect of ordering.

The `request_approval` tool is never matched by these globs. It is excluded from
the gate so the agent can ask for approval without triggering the gate itself.

`timeout_secs` defaults to `300` for webhooks. If the webhook does not return a
decision before the timeout, the tool does not run.

## Orchestration example

```toml theme={null}
[agent]
name = "SRE Orchestrator"
system_prompt = "Route operational work to the right worker."
turn_depth = 8

[agent.llm]
provider = "openai"
api_key = "{{ env.OPENAI_API_KEY }}"
model = "gpt-5.2"
context_window = 200_000

[mcp.servers.k8s]
transport = "http_streamable"
url = "http://k8s-mcp:8080/mcp"

[hitl]
require_approval = ["k8s_apply_*", "restart_*", "delete_*"]

[hitl.route]
mode = "webhook"
url = "https://approvals.example.com/aura"
timeout_secs = 300

[orchestration]
enabled = true
max_planning_cycles = 2

[orchestration.worker.operations]
description = "Operational changes that may affect running services"
preamble = "Use Kubernetes tools carefully. Do not retry denied actions."
mcp_filter = ["k8s_*", "restart_*"]
```

The gate is added before the worker's MCP tools execute. A denied call returns a
successful blocked tool result to the worker:

```text theme={null}
Tool call blocked by human approval denial: maintenance window not open. Do not execute this action.
```

The worker sees that message and can explain the denial to the user. The MCP tool
itself is not called.

## Webhook request

Aura sends a JSON request to the configured webhook URL. The request uses a flat
wire shape with `kind` tags for `scope` and `origin`:

```json theme={null}
{
  "version": 1,
  "decision_id": "019edc27-e4d2-7950-abbf-e37a9060887d",
  "request_id": "req_d6df99fd5c8b4eb6af6e0de049e9c0d6",
  "scope": {
    "kind": "worker",
    "run_id": "019edc27-d7e1-73d2-ac33-1a1e21b3fffd",
    "task_id": 0,
    "worker": "operations",
    "session_id": "cs_264c5a09257c4089886cb00ae2ef03c4"
  },
  "origin": {
    "kind": "config_gate",
    "matched_pattern": "restart_*"
  },
  "items": [
    {
      "tool_name": "restart_deployment",
      "arguments": {
        "namespace": "prod",
        "deployment": "api"
      }
    }
  ]
}
```

Fields:

| Field         | Meaning                                                                                                                                                                                                         |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version`     | Approval webhook protocol version.                                                                                                                                                                              |
| `decision_id` | Unique id for this approval decision.                                                                                                                                                                           |
| `request_id`  | Aura request id for the chat completion.                                                                                                                                                                        |
| `scope`       | Which agent surface is asking, independent of why. `kind = "single"` for a single-agent run or `kind = "worker"` for an orchestration worker. Both HITL origins carry a scope.                                  |
| `origin`      | Why approval was requested. `kind = "config_gate"` (a configured glob matched the tool call, carries the matched glob) or `kind = "agent_requested"` (the agent called `request_approval`, carries the reason). |
| `items`       | Tool call payloads awaiting approval. One item per request.                                                                                                                                                     |

## Webhook response

Approve the tool call:

```json theme={null}
{ "approved": true }
```

Deny the tool call, optionally with a reason:

```json theme={null}
{ "approved": false, "reason": "maintenance window not open" }
```

Response behavior:

| Outcome               | Tool execution     | Worker-visible result                                                |
| --------------------- | ------------------ | -------------------------------------------------------------------- |
| `approved: true`      | Runs the tool.     | The worker receives the MCP tool result.                             |
| `approved: false`     | Tool does not run. | The worker receives a blocked-action message with the denial reason. |
| Timeout               | Tool does not run. | The worker receives a tool error: approval timed out.                |
| Non-2xx response      | Tool does not run. | The worker receives a tool error: approval channel error.            |
| Invalid JSON response | Tool does not run. | The worker receives a tool error: approval channel error.            |

## Sign and Verify Approval Webhooks

Signing is an opt-in HMAC-SHA256 (hash-based message authentication code) "root
of trust" for the webhook exchange. It verifies that the party returning a
decision (the responder) is the same party Aura sent the request to. This is the
same pattern Stripe and GitHub use to verify webhook signatures. The same
signing also applies to the conversational route's decision submission, so both
approval paths share one root of trust. Signing is off by default. When no secret
is set, behavior matches the pre-signing default and Aura logs a warning that
verification is disabled. Signing is configured entirely by environment
variables, read once at startup.

### Enable Signing

To enable signing, set the `AURA_HITL_WEBHOOK_SECRET` environment variable. The
secret's raw UTF-8 bytes are used directly as the HMAC key, so the secret must be
at least 32 bytes long. The recommended way to generate one is
`openssl rand -hex 32`, which produces a 64-character value used as-is with no
decoding step.

A present-but-empty or whitespace-only secret is a hard startup error, so Aura
fails loudly rather than silently disabling signing. When a secret is set
together with an `http://` (unencrypted) webhook URL, Aura refuses to start.
HTTPS is required when signing is on. Secrets are read once at startup, so
changing any of them requires a restart. At startup, Aura logs whether webhook
signing is enabled, so you can confirm the secret was picked up after a restart.
The off state logs a warning that verification is disabled.

Setting `AURA_HITL_WEBHOOK_SECRET_SECONDARY` without a primary is a startup
error, consistent with the empty or short primary case.

| Environment variable                 | Default            | Description                                                                                                                                                        |
| ------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `AURA_HITL_WEBHOOK_SECRET`           | none (signing off) | Primary HMAC key. Presence enables signing. Raw UTF-8 bytes are used as the key, with a minimum of 32 bytes. An empty or whitespace-only value is a startup error. |
| `AURA_HITL_WEBHOOK_SECRET_SECONDARY` | none               | Optional second key, used for verification only, to support zero-downtime key rotation. Setting it without a primary is a startup error.                           |
| `AURA_HITL_WEBHOOK_TOLERANCE_SECS`   | `300`              | Allowed timestamp skew window, in seconds. Valid range is 1 to 86400.                                                                                              |

```bash theme={null}
export AURA_HITL_WEBHOOK_SECRET=$(openssl rand -hex 32)     # min 32 bytes; presence enables signing
export AURA_HITL_WEBHOOK_SECRET_SECONDARY=...               # optional; verification-only, for rotation
export AURA_HITL_WEBHOOK_TOLERANCE_SECS=300                 # default 300; range 1-86400
```

### Request Signature Aura Sends

The next two subsections are the contract for whoever builds the webhook
receiver.

Aura's outbound approval-request POST carries two headers:

```http theme={null}
X-Aura-Signature-256: sha256=<64 lowercase hex>
X-Aura-Timestamp: <unix seconds>
```

The signed string is the canonical form
`{unix_timestamp}.{context}.{raw_request_body}`. Aura computes HMAC-SHA256 over
that string with the secret and hex-encodes the result in lowercase. The approval
request uses the context `approval-request:{decision_id}`.

`raw_request_body` is the exact bytes of the request body as sent on the wire.
Compute and verify the signature over those raw bytes before parsing the JSON,
because parsing and re-encoding the body can change the bytes and break the signature.

The context label ties each signature to a specific decision and direction. As a
result, a captured signature can't be replayed against a different decision or in
the other direction.

A receiver verifies Aura's request, and signs its decision, with the same
computation:

```text theme={null}
signed_string = "{timestamp}.{context}.{raw_body}"
signature     = "sha256=" + lowercase_hex(hmac_sha256(secret, signed_string))
# Verify an inbound request: recompute with context "approval-request:{decision_id}"
#   and compare against the X-Aura-Signature-256 header in constant time.
# Sign an outbound decision: compute with context "approval-decision:{decision_id}"
#   and send it in the X-Aura-Signature-256 and X-Aura-Timestamp headers.
```

### Responder Obligation

When signing is enabled, the party that returns a decision (the responder) must
also sign it. Use the same secret, the same `X-Aura-Signature-256` and
`X-Aura-Timestamp` headers, and the context `approval-decision:{decision_id}`.
This applies to both the webhook route's HTTP response body and the
conversational route's `POST /v1/approvals/{decision_id}` request.

The responder signs its decision with the same canonical string
`{unix_timestamp}.{context}.{raw_response_body}`, using the context
`approval-decision:{decision_id}` and the same lowercase-hex `sha256=<hex>`
format. The responder is a separate service. Give it the same secret value out
of band. It does not read Aura's environment variables. When the responder
cannot verify Aura's request signature, it should reject the request with a
non-2xx response, which Aura treats as an approval channel error.

Aura rejects an invalid, stale, or missing signature. A timestamp outside the
`AURA_HITL_WEBHOOK_TOLERANCE_SECS` window counts as stale.

| Where the signature check fails                                | Result                                                                |
| -------------------------------------------------------------- | --------------------------------------------------------------------- |
| Webhook response leg                                           | The tool fails closed. The worker receives an approval channel error. |
| Approval ingress endpoint (`POST /v1/approvals/{decision_id}`) | Aura returns a uniform `401`.                                         |

Signature direction and context labels at a glance:

| Direction                                                                   | Signed by | Context label                     |
| --------------------------------------------------------------------------- | --------- | --------------------------------- |
| Approval request (outbound)                                                 | Aura      | `approval-request:{decision_id}`  |
| Approval decision (webhook response and `POST /v1/approvals/{decision_id}`) | Responder | `approval-decision:{decision_id}` |

### Rotate the Signing Key

Rotate the key with a three-restart procedure. Steps 1 and 3 are rolling
restarts. Each must finish on every Aura instance before you move to the next
step.

1. Set the new key as `AURA_HITL_WEBHOOK_SECRET_SECONDARY` on every Aura instance
   and restart, so Aura accepts both the old and new keys when it verifies
   decisions. Complete this on all instances before continuing.
2. Update each responder to sign its decisions with the new key. During this
   window, keep each responder able to verify Aura's requests with both the old
   and new keys, because Aura still signs its outbound requests with the old key
   until step 3.
3. Promote the new key to `AURA_HITL_WEBHOOK_SECRET`, clear the secondary, and
   restart every Aura instance. After this, Aura signs and verifies with the new
   key only, so each responder must also be verifying Aura's requests with the
   new key.

A restart can interrupt in-flight approvals. See
[Current limitations](#current-limitations) for how the session store affects
approvals that are in progress.

## SSE lifecycle events

Approval routes emit lifecycle events on streaming responses. These events are
emitted even when `AURA_CUSTOM_EVENTS=false` because clients may need to react to
approval state.

```text theme={null}
event: aura.approval_requested
data: { ... }

event: aura.approval_pending
data: { ... }

event: aura.approval_completed
data: { ... }
```

`aura.approval_requested` includes `decision_id`, `tool_name`, `origin`, and
`scope`. `aura.approval_pending` is emitted only by the conversational route and
contains the attended prompt payload that an Aura-aware client renders before
posting a decision. `aura.approval_completed` includes `decision_id`, terminal
`outcome`, `duration_ms`, and `scope`. Outcome kinds are `approved`, `denied`,
`timed_out`, `cancelled`, and `errored`; `errored` means the approval channel
failed before a human decision was obtained.

## Conversational route

Use conversational routing when the approver is present on the chat stream. The
server parks the worker tool call, sends `aura.approval_pending` over SSE, and
waits for a decision on the approval ingress endpoint:

```toml theme={null}
[hitl]
require_approval = ["multiply", "divide", "dangerous_*"]

[hitl.route]
mode = "conversational"
timeout_secs = 120
```

The chat request must set `stream=true`. Aura rejects non-streaming requests for
conversational HITL because there is no channel for the pending approval prompt.

An attended client resolves a pending approval by POSTing the same decision shape
as a webhook response:

```http theme={null}
POST /v1/approvals/{decision_id}
```

```json theme={null}
{ "approved": false, "reason": "maintenance window not open" }
```

When signing is enabled, the responder must sign the decision POST with the
`approval-decision:{decision_id}` context. See
[Sign and Verify Approval Webhooks](#sign-and-verify-approval-webhooks) for the
signing contract.

The AURA CLI supports this flow in HTTP mode. It renders
`aura.approval_pending`, prompts for approve/deny, and POSTs the decision back to
the server. One-shot CLI mode fails loud instead of prompting because it has no
interactive approval surface.

## Webhook manual smoke test

Start a webhook service that accepts the request shape above and returns an
approval response. Then run Aura with an orchestration config that uses:

```toml theme={null}
[hitl]
require_approval = ["mock_tool"]

[hitl.route]
mode = "webhook"
url = "http://localhost:9988"
timeout_secs = 300
```

Use `mock_tool` (the tool name from the bundled math orchestration example config)
so the glob matches a tool the worker actually calls. Put the webhook on a
different port than the mock MCP server (9999) to avoid a collision.

Ask an orchestration worker to use the gated tool. An approval should let the
tool run. A denial with a custom reason should produce a successful blocked tool
result containing that reason.

## Conversational manual smoke test

Run Aura with an orchestration config that uses `mode = "conversational"`, a
route timeout shorter than `[orchestration.timeouts].per_call_timeout_secs`, and
at least one gated worker tool. Connect with the AURA CLI in HTTP mode and send a
query that forces the worker to call the gated tool.

Expected behavior:

* The CLI renders an approval prompt from `aura.approval_pending`.
* Approving the prompt POSTs to `/v1/approvals/{decision_id}` and lets the tool
  run.
* Denying the prompt POSTs the denial and returns blocked-action feedback to the
  worker.

## Current limitations

* The webhook route is synchronous. Aura waits for the webhook response during
  the tool call.
* With the default in-memory session store, conversational approvals are
  single-instance, so only the server process that emitted
  `aura.approval_pending` can resolve them.
* You can resume a parked approval on a different pod by configuring the optional
  Redis or Valkey session store. A `POST /v1/approvals/{id}` request that lands on
  any instance then resolves an approval parked on another. See
  [Session Store](/aura/configuration-reference#session-store-durable-and-multi-pod-deployments).
* Without a signing secret configured, the webhook and conversational approval
  traffic is unauthenticated. See
  [Sign and Verify Approval Webhooks](#sign-and-verify-approval-webhooks) to add
  an HMAC-SHA256 root of trust.
