Skip to main content

Travel Refund - MAP + Plan-Scoped HITL

Travel refunds are a wonderful fit for multi-agent plans (MAP) with human-in-the-loop approval. A refund agent needs to look up the itinerary, apply fare rules, compute a refund amount, check compliance, and then commit the money. The first four steps are safe to automate. The fifth step is where a human with the authority to release revenue has to step in. MAP runs the plan end to end; AxonFlow's plan-scoped HITL endpoints give the agent a clean pause/resume surface at exactly the steps that need review.

This tutorial walks through a travel refund MAP plan end to end on AxonFlow v7.4.0, demonstrating:

  • MAP confirm execution mode - the plan generator emits a plan and the orchestrator pauses at every gated step
  • Plan-scoped HITL approve / reject at /api/v1/plans/{id}/steps/{step_id}/approve|reject (Evaluation+ since v7.4.0)
  • Plan-scoped pending-approvals listing at /api/v1/plans/approvals/pending?plan_id=... (new in v7.4.0) so a reviewer tool can render exactly the approvals for one plan without filtering client-side
  • Cross-plane response parity - the MAP plan-scoped responses carry plan_id populated plus the same retry_context, approval_id, approved_by / approved_at, and policies_matched fields the WCP workflow-scoped responses do
Scope

This tutorial uses platform features available at these tiers:

  • MAP plan-scoped approve / reject - Evaluation+ since v7.4.0 (Enterprise-only before)
  • MAP plan-scoped pending listing (GET /api/v1/plans/approvals/pending) - Evaluation+ (new in v7.4.0)
  • MAP confirm execution mode - the entry side that creates the approval-gated plan, currently Enterprise-only

Run the plan-scoped HITL endpoints on an Evaluation license. Full end-to-end plan creation in confirm mode requires an Enterprise license until the follow-up that lowers confirm/step execution modes to Evaluation+ lands.

Free Evaluation license: getaxonflow.com/evaluation-license.

The scenario

"Star Travel" is an online travel agency. Customers request refunds through an AI agent. For low-value refunds (under €500) the agent processes automatically. Anything above €500 has to route to a supervisor for approval before the money leaves the bank. The supervisor team has a reviewer dashboard that lists pending refunds per customer-service queue; the dashboard needs to scope to one plan at a time so an agent can focus on their own open cases without seeing every other agent's queue.

Before AxonFlow:

  • The refund agent had no shared state between "plan paused" and "plan resumed", so a retried plan would submit the refund again on the second attempt
  • The reviewer dashboard had to filter all pending approvals client-side, or branch on plane to pull from WCP vs MAP
  • A rejected approval followed by a retry could leak an approval pointer to a different booking

With AxonFlow v7.4.0, every one of those collapses into one wire contract.

Flow

   User asks for refund


POST /api/request
{ request_type: multi-agent-plan,
context.execution_mode: "confirm" }


MAP generates plan
(steps: verify booking,
apply rules, compute,
request supervisor approval,
submit refund)


/api/request execute-plan


Step 0 (verify booking) - require_approval
(triggered by a policy on step_name
'supervisor-review' with amount > 500)


Reviewer polls /api/v1/plans/approvals/pending?plan_id=...


Reviewer approves via
POST /api/v1/plans/{plan_id}/steps/{step_id}/approve
{ "comment": "Approved - valid cancellation per fare rules" }


MAP resumes, completes remaining steps


Refund committed

The plane-scoped pending listing keeps the reviewer dashboard scoped to the plan under review. Every pending entry carries plan_id, so a UI that renders a plan detail page only needs one call, not a two-phase "list all pending, then filter".

Prerequisites

  • AxonFlow platform v7.4.0 or later with HITL enabled (AXONFLOW_HITL_ENABLED=true)
  • MAP confirm execution mode enabled (Enterprise deployment or DEPLOYMENT_MODE=evaluation with Evaluation license)
  • One SDK installed: Go 5.6.0+, TypeScript 5.6.0+, Python 6.6.0+, or Java 5.7.0+
  • A tenant policy in place that triggers require_approval on the supervisor-review step (we create it below)

Step 0 - Create the policy that gates the supervisor-review step

This policy inspects the step name and fires require_approval on any step named supervisor-review. In production you would match on step-input fields (refund amount, customer tier, booking type) for the fire condition.

curl -X POST http://localhost:8080/api/v1/dynamic-policies \
-H "Authorization: Basic $(echo -n "${CLIENT_ID}:${LICENSE_KEY}" | base64)" \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: ${TENANT_ID}" \
-d '{
"name": "travel-refund-supervisor-gate",
"description": "Route supervisor-review steps to a human approval queue",
"type": "content",
"category": "dynamic-travel",
"priority": 900,
"enabled": true,
"severity": "high",
"trigger_types": ["wcp_step_gate"],
"conditions": [
{"field": "context.step_name", "operator": "regex", "value": "supervisor-review"}
],
"actions": [{
"type": "require_approval",
"config": {"reason": "High-value refund requires supervisor review"}
}]
}'

The policy lands in the tenant policy table and applies to every step gate emitted for workflows owned by this tenant. MAP confirm mode threads every plan step through /gate, so this policy fires naturally when the plan hits the supervisor-review step.

Step 1 - Create the plan

Use the agent-level /api/request with request_type: "multi-agent-plan" and execution_mode: "confirm". MAP generates a plan and stores it; every step will be gated before execution.

import os
from axonflow import AxonFlow
from axonflow.types import ExecutionMode

client = AxonFlow(
endpoint=os.getenv("AXONFLOW_ENDPOINT", "http://localhost:8080"),
client_id=os.getenv("AXONFLOW_CLIENT_ID", "star-travel-prod"),
client_secret=os.getenv("AXONFLOW_CLIENT_SECRET", ""),
)

booking_ref = "PNR-8QR4WT"

# Generate a MAP plan in confirm mode so each step pauses at /gate
plan_resp = await client.generate_plan(
query=f"Process refund for booking {booking_ref}",
domain="travel",
execution_mode=ExecutionMode.CONFIRM,
)
plan_id = plan_resp.plan_id
print(f"Plan created: {plan_id}")

Step 2 - Execute the plan, pause at supervisor-review

Executing a confirm-mode plan tells the orchestrator to walk the steps, pausing at every gated step.

exec_resp = await client.execute_plan(plan_id)
print(f"Plan status: {exec_resp.status}")

The plan reaches the supervisor-review step and pauses. The require_approval decision writes a row to the HITL queue and persists the step state.

Step 3 - Reviewer dashboard calls the plane-scoped pending listing

The key new endpoint. The reviewer UI is scoped to one plan at a time, so it passes ?plan_id= to get exactly that plan's pending steps. The response has plan_id populated on every entry so the UI can render plan-context headers without a second lookup.

# Reviewer UI calls this every few seconds while open
pending = await client.get_pending_plan_approvals(plan_id=plan_id)

for entry in pending.pending_approvals:
print(f"Plan: {entry.plan_id}")
print(f"Step: {entry.step_id} ({entry.step_name})")
print(f"Reason: {entry.decision_reason}")
print(f"Policies: {[p['policy_name'] for p in entry.policies_matched or []]}")
print(f"Created at: {entry.created_at}")

What the WCP-plane listing would have shown. If the reviewer UI had called /api/v1/workflows/approvals/pending instead, the same entry would come back, but without the plan_id field. The UI would then need to look up the plan context separately. The MAP-plane endpoint closes that round-trip.

Step 4 - Supervisor approves via the MAP plane-scoped endpoint

The reviewer clicks "Approve" in the dashboard. The backing call hits the MAP plan-scoped approve endpoint with a comment (min 10 chars, required for the audit trail).

The SDKs expose WCP workflow-scoped approve/reject (approveStep(workflowId, stepId, comment) and equivalents). For the MAP plan-scoped approve/reject endpoints, use a direct HTTP call - the SDK wrappers for /api/v1/plans/{plan_id}/steps/{step_id}/approve|reject are a planned follow-up. Both endpoints share the same response shape, so you can consume the JSON with your SDK's generic HTTP helper.

import httpx
import base64

# Plan-scoped approve is a direct HTTP call today; the response shape
# matches WCP approve and the SDK type ApproveStepResponse decodes it.
auth = base64.b64encode(
f"{os.environ['AXONFLOW_CLIENT_ID']}:{os.environ['AXONFLOW_CLIENT_SECRET']}".encode()
).decode()

async with httpx.AsyncClient() as http:
resp = await http.post(
f"http://localhost:8080/api/v1/plans/{plan_id}/steps/{step_id}/approve",
headers={
"Authorization": f"Basic {auth}",
"X-Tenant-ID": os.environ["AXONFLOW_CLIENT_ID"],
"X-User-ID": "[email protected]",
},
json={
"comment": "Approved - valid cancellation per fare rules; certificate reviewed"
},
)
resp.raise_for_status()
data = resp.json()

print(f"decision={data['decision']} approved_by={data['approved_by']}")
print(f"approval_id={data['approval_id']}")

Example response (note plan_id populated - the acknowledged asymmetry with the WCP endpoint):

{
"workflow_id": "wf-map-travel-001",
"plan_id": "plan_1776860545_t8slb7jn",
"step_id": "step_3_supervisor-review",
"decision": "allow",
"reason": "Approved: High-value refund requires supervisor review",
"approval_status": "approved",
"approval_id": "75a2e087-b9ef-5645-8662-5bd2147c47e0",
"approved_by": "[email protected]",
"approved_at": "2026-04-22T14:05:00Z",
"policies_matched": [
{
"policy_id": "f9b22075-7e59-4ba2-a31e-bb6e059ae96b",
"policy_name": "travel-refund-supervisor-gate",
"action": "require_approval",
"risk_level": "high"
}
],
"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": "",
"last_decision": "require_approval",
"first_attempt_at": "2026-04-22T14:00:00Z",
"last_attempt_at": "2026-04-22T14:00:00Z"
},
"message": "Step approved"
}

Step 5 - MAP resumes, plan completes

Once the require_approval decision is resolved to allow, the MAP orchestrator picks up execution at the gated step and walks the rest of the plan. The refund commit step runs against the payment provider; final status becomes completed.

The reviewer UI re-polls /api/v1/plans/approvals/pending?plan_id=<plan_id> and sees the entry gone. The UI can now close the ticket.

Reject path

If the supervisor finds the refund request invalid (say the customer is outside the cancellation window and the doctor certificate does not apply to this booking), they reject with an audit reason:

curl -X POST "http://localhost:8080/api/v1/plans/${PLAN_ID}/steps/${STEP_ID}/reject" \
-H "Authorization: Basic $(echo -n "${CLIENT_ID}:${LICENSE_KEY}" | base64)" \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: ${TENANT_ID}" \
-H "X-User-ID: [email protected]" \
-d '{
"reason": "Booking outside cancellation window; medical exception does not apply to non-refundable fares"
}'

Response shape is the same as approve, with decision: "block", approval_status: "rejected", and rejected_by / rejected_at populated instead of approved_*. The plan aborts; the customer gets a refund-denied notification via the downstream notification step.

The safety story - plane-scoped listing makes reviewer tools tractable

Before v7.4.0 the reviewer dashboard had two bad options:

  1. Call /api/v1/workflows/approvals/pending and filter client-side by looking up every entry's plan_id via a separate API call per entry. With 50 pending plans active, that is 51 API calls per dashboard refresh.
  2. Call the Enterprise-only /api/v1/hitl/queue surface. Plane-neutral but gated at Enterprise tier; Evaluation customers did not have it.

v7.4.0 adds a third option that fits most reviewer tooling:

  • Call /api/v1/plans/approvals/pending?plan_id=<id> - one request, scoped exactly to the plan under review. Every returned entry has plan_id populated so the UI can render plan-context without a second call.

For list views that span multiple plans, drop the filter:

  • GET /api/v1/plans/approvals/pending returns every MAP-backed pending approval in the tenant with plan_id populated on every row. The UI can group by plan_id client-side without a separate lookup table.

What this protects you from

  • Losing plan context on the reviewer side. Before v7.4.0 an approval listing entry did not tell you which plan it belonged to on the MAP plane; the reviewer dashboard had to reconstruct that mapping itself. Now plan_id is a first-class field.
  • Divergent wire shapes across planes. ADR-046 locks WCP and MAP HITL responses to the same field set modulo the acknowledged plan_id asymmetry. A new field added to approve/reject automatically surfaces on both planes.
  • An approval pointer leaking to a different customer's plan. idempotency_key on step gates (v7.3.0) plus the approval_id UUID v5 derivation ensures an approved decision can only be acted on for the exact workflow + step pair that was reviewed. See the Healthcare Prior Auth tutorial for the full safety story on idempotency keys.

What this does not cover

  • MAP confirm execution mode tier gate. Currently Enterprise-only (v7.4.0 lowered the approve/reject and pending endpoints, not the confirm mode itself). A separate follow-up will lower confirm/step execution modes to Evaluation+.
  • Cross-plan plan_id enforcement. A rejected plan's approval_id cannot be reused on a different plan. Same-workflow idempotency is enforced; cross-workflow enforcement is a planned future enhancement.
  • Reviewer escalation SLAs. Auto-approve on SLA expiry and multi-level approval remain Enterprise-only.