Skip to main content

Human-in-the-Loop (HITL)

Human-in-the-loop controls let AxonFlow pause a risky workflow step and wait for a person to decide whether the step should continue. This is where governance stops being "just logging" and becomes an operational control.

The core trigger is the require_approval action. You can reach it from policies, workflow gating, or orchestrated execution flows where the next step should not proceed automatically.

When Teams Use HITL

Typical cases include:

  • financial workflows that move money, apply limits, or change risk exposure
  • healthcare or legal assistants where high-risk outputs need review
  • support or operations flows that can touch regulated customer data
  • code or infrastructure actions where an LLM can propose a change but not execute it alone

In other words, HITL is for the places where "the model is usually right" is not an acceptable operating standard.

The require_approval Action

This policy pattern turns a matched condition into a gated workflow step:

name: "high-value-transaction-oversight"
pattern: "(amount|value|total).*\\$[1-9][0-9]{6,}"
action: require_approval
severity: high

When that action is returned, the runtime behavior depends on your tier and the workflow surface you are using.

How The Approval Flow Works

The important distinction is this:

  • Community lets you model and detect approval-worthy situations, but it does not give you a real approval queue to action them.
  • Evaluation adds a real approval queue for governed workflow execution.
  • Enterprise turns that into an operational workflow with UI, escalation, and broader review tooling.

Tier Behavior

CapabilityCommunityEvaluationEnterprise
require_approval decision
Real approval queue for governed workflow steps
Approve/reject API for queued workflow steps
Evaluation limits100 pending, 24h expiryn/a
Customer Portal approval dashboard
Richer reviewer operating model, notifications, escalation

This means Community is still useful for engineers validating that the right steps are being flagged, but once you want an actual reviewer to approve or reject those steps, you need Evaluation or Enterprise.

Workflow Approval APIs

For governed workflows, the approval surface is exposed through workflow approval endpoints on the workflow-control plane. This is the API shape most teams will use first because it fits directly into multi-agent workflow execution.

List Pending Approvals

curl -X GET "http://localhost:8080/api/v1/workflows/approvals/pending" \

This endpoint returns pending workflow steps that need review before the workflow can proceed.

Approve A Step

curl -X POST "http://localhost:8080/api/v1/workflows/wf-abc-123/steps/step-2/approve" \
-H "Content-Type: application/json" \
-H "X-User-ID: compliance-officer-7" \
-d '{"comment": "Approved after full audit review of the payment intent"}'

Reject A Step

curl -X POST "http://localhost:8080/api/v1/workflows/wf-abc-123/steps/step-2/reject" \
-H "Content-Type: application/json" \
-H "X-User-ID: compliance-officer-7" \
-d '{"reason": "Output contains PII that was not redacted"}'

Both endpoints return the full approval response — same shape as the step-gate response — so a reviewer tool can render the decision, policy trail, and retry state without a second API call:

{
"workflow_id": "wf-abc-123",
"step_id": "step-2",
"decision": "allow",
"reason": "Approved: High-value transfer requires oversight",
"approval_status": "approved",
"approval_id": "318a270f-7b42-5c56-a191-8dbd1bf2e1e4",
"approved_by": "compliance-officer-7",
"approved_at": "2026-04-22T10:05:00Z",
"policies_matched": [
{
"policy_id": "f9b22075-7e59-4ba2-a31e-bb6e059ae96b",
"policy_name": "High-Value Wire Transfer Oversight",
"action": "require_approval",
"risk_level": "high",
"allow_override": false,
"policy_description": "Require human approval on transactions above $10,000"
}
],
"retry_context": {
"gate_count": 1,
"completion_count": 0,
"prior_completion_status": "none",
"prior_output_available": false,
"prior_output": null,
"prior_completion_at": null,
"idempotency_key": "payment-intent-123",
"last_decision": "require_approval",
"first_attempt_at": "2026-04-22T10:00:00Z",
"last_attempt_at": "2026-04-22T10:00:00Z"
},
"message": "Step approved"
}

decision flips to allow on a successful approval (the step can now proceed) and block on rejection (workflow aborted). retry_context mirrors the Retry Semantics & Idempotency block, so the reviewer's confirmation carries the same counters and idempotency key the gate call surfaced.

MAP Plan-Scoped Equivalents

The multi-agent planning (MAP) surface exposes a plan-scoped mirror of the workflow endpoints, including a plane-scoped pending-approvals listing:

# Approve / reject a step within a MAP plan (confirm or step execution mode)
curl -X POST "http://localhost:8080/api/v1/plans/{plan_id}/steps/{step_id}/approve"
curl -X POST "http://localhost:8080/api/v1/plans/{plan_id}/steps/{step_id}/reject"

# List MAP-plane pending approvals (plan_id populated on every entry)
curl -X GET "http://localhost:8080/api/v1/plans/approvals/pending"

# Or scoped to a single plan
curl -X GET "http://localhost:8080/api/v1/plans/approvals/pending?plan_id=plan-abc123"

The MAP responses use the same shape as the WCP ones plus a plan_id field, so a reviewer tool that integrates against either plane does not need to branch on which path the request came through. The underlying workflow_steps row, retry counters, and HITL queue entry are shared between the two planes — the unified response is just the projection of that shared state through one helper.

The plane-scoped pending endpoints (/api/v1/workflows/approvals/pending on WCP and /api/v1/plans/approvals/pending on MAP) are the reviewer-tool convenience surface. Clients that want a plane-neutral view can use /api/v1/hitl/queue (Enterprise), which already sees both planes.

See HITL Approval Gates for the full page covering the workflow-oriented approval surface.

Retries And Idempotency With Approvals

When an agent re-calls /gate on a step that is still waiting on a reviewer, the response preserves the require_approval decision and carries a retry_context object that surfaces the number of gate attempts, the prior decision, and whether a /complete ever landed. After the approval is granted and execution completes, a later gate call on the same step shows prior_completion_status == "completed", which lets a retried agent reuse the prior outcome instead of re-running the business action.

If the triggering policy or the caller sets an idempotency_key on the first gate call, that key is bound to the approval for the step's lifetime. A subsequent gate or /complete with a different key is rejected with 409 IDEMPOTENCY_KEY_MISMATCH, so an approved decision cannot be retargeted to a different business transaction — only the transaction the reviewer actually approved can proceed. Same-workflow enforcement applies in all tiers; cross-workflow enforcement is a planned future enhancement. See Retry Semantics & Idempotency for the full wire shape.

Enterprise Queue Operations

Enterprise also exposes a broader approval-queue operating surface for organizations that need a more explicit review system:

  • POST /api/v1/hitl/queue
  • GET /api/v1/hitl/queue
  • GET /api/v1/hitl/queue/{id}
  • POST /api/v1/hitl/queue/{id}/approve
  • POST /api/v1/hitl/queue/{id}/reject
  • POST /api/v1/hitl/queue/{id}/override
  • GET /api/v1/hitl/queue/{id}/history
  • GET /api/v1/hitl/stats

That surface is what supports broader reviewer operations, queue inspection, overrides, and richer portal experiences. It is one of the clearest examples of the difference between "I need approval gates" and "I need an approval operating model."

Creating Requests From The SDK

Each AxonFlow SDK exposes create_hitl_request (snake_case in Python / Rust, camelCase in TypeScript / Java, PascalCase in Go) so agent-framework callers can enqueue an approval row without hand-rolling the HTTP call. The full 4-step flow is:

  1. Gate evaluates require_approval (via pre_check / check_tool_input).
  2. Caller invokes create_hitl_request(...) to enqueue the row.
  3. Caller polls get_hitl_request(approval_id) until the row reaches a terminal state — or supplies notify_url so the platform fires a signed webhook on the transition (see Outbound Webhook Callback below).
  4. Caller resumes the agent or denies the call based on the decision.

The required fields are client_id, original_query, and request_type. Policy attribution (triggered_policy_id, triggered_policy_name, trigger_reason), severity, the new notify_url callback, compliance metadata, and an expiry override are optional.

Python (v8.3.0+)

from axonflow import AxonFlow
from axonflow.hitl import HITLCreateInput

async with AxonFlow(endpoint="https://agent.example.com") as client:
req = await client.create_hitl_request(
HITLCreateInput(
client_id="loan-desk",
original_query="disburse $50000 to cust-001",
request_type="adk-tool",
triggered_policy_id="loan-amount-cap",
triggered_policy_name="Loan amount cap",
trigger_reason="Disbursement above $10k requires manager approval",
severity="high",
notify_url="https://workflows.example.com/hooks/loan-approve",
)
)
print(req.request_id)

TypeScript (v8.3.0+)

import { AxonFlow, HITLCreateInput } from '@axonflow/sdk';

const client = new AxonFlow({ endpoint: 'https://agent.example.com', /* ... */ });

const req = await client.createHITLRequest({
client_id: 'loan-desk',
original_query: 'disburse $50000 to cust-001',
request_type: 'adk-tool',
triggered_policy_id: 'loan-amount-cap',
triggered_policy_name: 'Loan amount cap',
trigger_reason: 'Disbursement above $10k requires manager approval',
severity: 'high',
notify_url: 'https://workflows.example.com/hooks/loan-approve',
});
console.log(req.request_id);

Go (v8.3.0+)

import axonflow "github.com/getaxonflow/axonflow-sdk-go/v8"

client := axonflow.NewClient(axonflow.AxonFlowConfig{
Endpoint: "https://agent.example.com",
})
req, err := client.CreateHITLRequest(axonflow.HITLCreateInput{
ClientID: "loan-desk",
OriginalQuery: "disburse $50000 to cust-001",
RequestType: "adk-tool",
TriggeredPolicyID: "loan-amount-cap",
TriggeredPolicyName: "Loan amount cap",
TriggerReason: "Disbursement above $10k requires manager approval",
Severity: "high",
NotifyURL: "https://workflows.example.com/hooks/loan-approve",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(req.RequestID)

Java (v8.3.0+)

import com.getaxonflow.sdk.AxonFlow;
import com.getaxonflow.sdk.AxonFlowConfig;
import com.getaxonflow.sdk.types.hitl.HITLTypes.HITLApprovalRequest;
import com.getaxonflow.sdk.types.hitl.HITLTypes.HITLCreateInput;

AxonFlow axonflow = AxonFlow.create(
AxonFlowConfig.builder().endpoint("https://agent.example.com").build());

HITLApprovalRequest req = axonflow.createHITLRequest(
HITLCreateInput.builder()
.clientId("loan-desk")
.originalQuery("disburse $50000 to cust-001")
.requestType("adk-tool")
.triggeredPolicyId("loan-amount-cap")
.triggeredPolicyName("Loan amount cap")
.triggerReason("Disbursement above $10k requires manager approval")
.severity("high")
.notifyUrl("https://workflows.example.com/hooks/loan-approve")
.build());
System.out.println(req.getRequestId());

A createHITLRequestAsync overload returning CompletableFuture<HITLApprovalRequest> is also available for non-blocking callers.

Rust (v0.5.0+)

use axonflow_sdk_rust::{AxonFlowClient, AxonFlowConfig, HITLCreateInput};

let client = AxonFlowClient::new(AxonFlowConfig::new("https://agent.example.com"))?;
let req = client.create_hitl_request(HITLCreateInput {
client_id: "loan-desk".into(),
original_query: "disburse $50000 to cust-001".into(),
request_type: "adk-tool".into(),
triggered_policy_id: Some("loan-amount-cap".into()),
triggered_policy_name: Some("Loan amount cap".into()),
trigger_reason: Some("Disbursement above $10k requires manager approval".into()),
severity: Some("high".into()),
notify_url: Some("https://workflows.example.com/hooks/loan-approve".into()),
..Default::default()
}).await?;
println!("{}", req.request_id);

Outbound Webhook Callback (notify_url)

When you create a HITL request via POST /api/v1/hitl/queue you can attach an optional notify_url. After the request reaches a terminal state — approved, rejected, overridden, or expired — the platform fires a signed HTTP POST to that URL. This removes the need for a polling sidecar in integrations that pause on a webhook (n8n's Wait-node "On Webhook Call" pattern, Google ADK workflows that resume from an external trigger).

Create a request with a callback:

curl -X POST "http://localhost:8080/api/v1/hitl/queue" \
-H "Content-Type: application/json" \
-H "Authorization: Basic $(echo -n my-org:secret | base64)" \
-d '{
"client_id": "loan-disbursement",
"original_query": "Disburse 50000000 IDR to merchant MR-7281",
"request_type": "payment_action",
"triggered_policy_id": "high-value-disbursement",
"triggered_policy_name": "High Value Disbursement",
"trigger_reason": "Amount exceeds tier limit",
"severity": "high",
"notify_url": "https://workflow.example.com/n8n/webhook/hitl-callback"
}'

The callback POST carries:

HeaderValue
Content-Typeapplication/json
User-Agentaxonflow-hitl/<version>
X-AxonFlow-Signaturesha256=<lowercase hex HMAC-SHA256 over body>
X-AxonFlow-Request-Id<approval_id UUID>
X-AxonFlow-Delivery-Id<per-attempt UUID>
X-AxonFlow-Eventhitl.approved / hitl.rejected / hitl.overridden / hitl.expired

Envelope body:

{
"approval_id": "318a270f-7b42-5c56-a191-8dbd1bf2e1e4",
"status": "approved",
"decided_by": "[email protected]",
"decided_at": "2026-05-23T10:05:00Z",
"original_query": "Disburse 50000000 IDR to merchant MR-7281",
"request_type": "payment_action",
"severity": "high",
"decision_envelope": {
"org_id": "my-org",
"tenant_id": "loan-tenant",
"client_id": "loan-disbursement",
"triggered_policy_id": "high-value-disbursement",
"comment": "Verified with merchant; release approved.",
"justification": ""
}
}

Verifying The Signature

Always verify the signature with a constant-time comparator (do not use == on strings — that leaks timing). Node.js example:

const crypto = require('crypto');

function verifyAxonFlowSignature(rawBody, signatureHeader, signingKey) {
if (typeof signatureHeader !== 'string' || signatureHeader === '') {
return false;
}
const expected = 'sha256=' + crypto
.createHmac('sha256', signingKey)
.update(rawBody)
.digest('hex');
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python example:

import hmac, hashlib

def verify_axonflow_signature(raw_body: bytes, signature_header: str, signing_key: bytes) -> bool:
expected = "sha256=" + hmac.new(signing_key, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature_header, expected)

Retry Behavior

The platform retries non-2xx responses (and transport errors) on a fixed exponential schedule: 5s → 30s → 5m — 4 attempts total. After the last attempt, the delivery is logged as GIVE-UP and the row stays in its terminal state. Receivers should treat the callback as best-effort and reconcile periodically via GET /api/v1/hitl/queue/{id} for any missed deliveries.

The retry loop runs entirely off-band: the approve/reject HTTP response returns the moment the database write commits. A slow or unreachable webhook receiver cannot stall the reviewer experience.

Signing Key Rotation

The signing key lives in the deployment's AXONFLOW_HITL_WEBHOOK_SIGNING_KEY env var. To rotate without dropped deliveries, your receiver should accept signatures from both the prior and new key during the overlap window matching your secret-sync cadence (typically a few minutes for cron-driven sync, longer for manual updates). When the rotation is complete, restart the agent and remove the prior key from the receiver's allowlist.

If the env var is unset, the dispatcher logs [HITL.Webhook] DROP AXONFLOW_HITL_WEBHOOK_SIGNING_KEY=unset per attempted delivery and the approve/reject response succeeds unchanged — so a misconfigured deployment will not block reviewer actions, but the receiver will never see a callback.

URL Validation

notify_url must use https:// (production) or http:// (local development inside the same VPC). Other schemes — file://, ftp://, data:, etc. — return 400 Bad Request at the API boundary, and defense-in-depth re-validation runs at dispatch time so a row inserted by any non-API path is still rejected before any outbound traffic.

Safe Retries With Idempotency-Key

POST /api/v1/hitl/queue accepts an optional Idempotency-Key HTTP header. If your workflow tool retries on transient failure (n8n's Retry on Fail, ADK's per-step idempotency, custom SDK retry loops), pass the same key on every retry and you will get back the original response byte-for-byte — no double row creation, no double audit record.

curl -X POST "http://localhost:8080/api/v1/hitl/queue" \
-H "Idempotency-Key: n8n-exec-abc123-node-Approve" \
-H "Content-Type: application/json" \
-H "Authorization: Basic $(echo -n my-org:secret | base64)" \
-d '{ ... }'

A cache hit adds an Idempotent-Replayed: true response header so your code can distinguish a replay from a fresh response if it cares. Cache rules:

  • Cached for 24 hours
  • 2xx and 4xx responses are cached (the deny is the idempotent answer for that exact input)
  • 5xx is NOT cached so a retry can hit a fresh attempt
  • Cross-tenant collisions are impossible — keys are scoped to your authenticated tenant + the specific endpoint

The same Idempotency-Key header is supported on POST /api/v1/mcp/check-input (policy decisions) and POST /api/v1/audit/tool-call (audit recording).

Key format: 1–256 characters matching ^[A-Za-z0-9_.:\-/]+$. UUIDs, workflow IDs like wf-abc-123:step-7, and namespaced node identifiers all work. A malformed key returns 400 Bad Request before the handler runs.

Recommended pattern for n8n: {$execution.id}-{$itemIndex}-{$node.name}. For ADK: {run_id}-{tool_invocation_id}. The intent is the same — produce a stable, unique-per-business-action key so retries dedupe but distinct actions get distinct rows.

Evaluation Limits

Evaluation is deliberately useful but bounded:

LimitValue
Max pending approvals100
Default expiry window24 hours
Primary interfaceAPI
Portal UIEnterprise only

Those limits are enough for meaningful evaluation, pilot rollouts, and pre-production workflow design. Teams usually outgrow them when:

  • several teams are using governed workflows at once
  • reviewers need a shared UI rather than scripts
  • operations wants escalation, auditability, and queue management

What Engineers Usually Need To Design

When you add HITL to a workflow, the technical design questions are usually:

  1. Which steps are genuinely high-risk enough to pause?
  2. Who is the reviewer, and how will they be identified?
  3. What happens on expiry or rejection?
  4. Does the app need an API-only review flow, or a dedicated operator UI?
  5. Is this a one-team pilot, or something that needs a durable enterprise approval process?

AxonFlow is strongest when those answers are explicit. A good HITL deployment is not "put approvals everywhere." It is "put approvals where human judgment changes risk."

Enterprise Operating Model

Enterprise customers get a stronger review experience around the same core control:

  • Customer Portal approval workflows for non-developer reviewers
  • better operational visibility into pending approvals
  • more sustainable review patterns once multiple workflows and teams share the same queue
  • cleaner evidence for governance and compliance review

That is why Evaluation is a good fit for validating approval-driven workflows, while Enterprise is the fit once the approval process itself becomes part of company operations.

Rollout Checklist

Use this page as one layer of the broader governance rollout: