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_idpopulated plus the sameretry_context,approval_id,approved_by / approved_at, andpolicies_matchedfields the WCP workflow-scoped responses do
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_approvalon 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.
- Python
- TypeScript
- Go
- Java
- curl
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}")
import { AxonFlow } from '@axonflow/sdk';
const client = new AxonFlow({
endpoint: process.env.AXONFLOW_ENDPOINT ?? 'http://localhost:8080',
clientId: process.env.AXONFLOW_CLIENT_ID ?? 'star-travel-prod',
clientSecret: process.env.AXONFLOW_CLIENT_SECRET ?? '',
});
const bookingRef = 'PNR-8QR4WT';
// Generate a MAP plan in confirm mode so each step pauses at /gate
const planResp = await client.generatePlan(
`Process refund for booking ${bookingRef}`,
'travel',
undefined,
{ executionMode: 'confirm' }
);
const planId = planResp.planId;
console.log(`Plan created: ${planId}`);
import (
"fmt"
"log"
"os"
"github.com/getaxonflow/axonflow-sdk-go/v5"
)
endpoint := os.Getenv("AXONFLOW_ENDPOINT")
if endpoint == "" {
endpoint = "http://localhost:8080"
}
client := axonflow.NewClient(axonflow.AxonFlowConfig{
Endpoint: endpoint,
ClientID: os.Getenv("AXONFLOW_CLIENT_ID"),
ClientSecret: os.Getenv("AXONFLOW_CLIENT_SECRET"),
})
bookingRef := "PNR-8QR4WT"
// Generate a MAP plan in confirm mode so each step pauses at /gate
planResp, err := client.GeneratePlanWithOptions(
fmt.Sprintf("Process refund for booking %s", bookingRef),
"travel",
axonflow.GeneratePlanOptions{
ExecutionMode: axonflow.ExecutionModeConfirm,
},
)
if err != nil {
log.Fatalf("generate plan: %v", err)
}
fmt.Printf("Plan created: %s\n", planResp.PlanID)
import com.getaxonflow.sdk.AxonFlow;
import com.getaxonflow.sdk.AxonFlowConfig;
import com.getaxonflow.sdk.types.PlanRequest;
import com.getaxonflow.sdk.types.PlanResponse;
AxonFlow client = AxonFlow.create(AxonFlowConfig.builder()
.endpoint(System.getenv().getOrDefault("AXONFLOW_ENDPOINT", "http://localhost:8080"))
.clientId(System.getenv().getOrDefault("AXONFLOW_CLIENT_ID", "star-travel-prod"))
.clientSecret(System.getenv("AXONFLOW_CLIENT_SECRET"))
.build());
String bookingRef = "PNR-8QR4WT";
// Generate a MAP plan in confirm mode; execution_mode is set via context
PlanResponse planResp = client.generatePlan(PlanRequest.builder()
.objective("Process refund for booking " + bookingRef)
.domain("travel")
.addContext("execution_mode", "confirm")
.build());
System.out.println("Plan created: " + planResp.getPlanId());
PLAN_RESP=$(curl -s -X POST http://localhost:8080/api/request \
-H "Authorization: Basic $(echo -n "${CLIENT_ID}:${LICENSE_KEY}" | base64)" \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: ${TENANT_ID}" \
-d '{
"query": "Process refund for booking PNR-8QR4WT",
"user_token": "'"${USER_TOKEN}"'",
"client_id": "'"${CLIENT_ID}"'",
"request_type": "multi-agent-plan",
"context": {
"domain": "travel",
"execution_mode": "confirm"
}
}')
PLAN_ID=$(echo "$PLAN_RESP" | jq -r '.plan_id // (.data.plan_id // empty)')
echo "Plan: $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.
- Python
- TypeScript
- Go
- Java
- curl
exec_resp = await client.execute_plan(plan_id)
print(f"Plan status: {exec_resp.status}")
const execResp = await client.executePlan(planId);
console.log(`Plan status: ${execResp.status}`);
execResp, err := client.ExecutePlan(planResp.PlanID)
if err != nil {
log.Fatalf("execute plan: %v", err)
}
fmt.Printf("Plan status: %s\n", execResp.Status)
PlanResponse execResp = client.executePlan(planResp.getPlanId());
System.out.println("Plan status: " + execResp.getStatus());
curl -X POST http://localhost:8080/api/request \
-H "Authorization: Basic $(echo -n "${CLIENT_ID}:${LICENSE_KEY}" | base64)" \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: ${TENANT_ID}" \
-d '{
"query": "execute",
"user_token": "'"${USER_TOKEN}"'",
"client_id": "'"${CLIENT_ID}"'",
"request_type": "execute-plan",
"context": { "plan_id": "'"${PLAN_ID}"'" }
}'
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.
- Python
- TypeScript
- Go
- Java
- curl
# 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}")
const pending = await client.getPendingPlanApprovals({ plan_id: planId });
for (const entry of pending.pending_approvals) {
console.log(`Plan: ${entry.plan_id}`);
console.log(`Step: ${entry.step_id} (${entry.step_name})`);
console.log(`Reason: ${entry.decision_reason}`);
console.log(`Created at: ${entry.created_at}`);
}
pending, err := client.GetPendingPlanApprovals(&axonflow.PendingApprovalsOptions{
PlanID: planID,
})
if err != nil {
log.Fatalf("list pending plan approvals: %v", err)
}
for _, entry := range pending.PendingApprovals {
fmt.Printf("Plan: %s\n", entry.PlanID)
fmt.Printf("Step: %s (%s)\n", entry.StepID, entry.StepName)
fmt.Printf("Reason: %s\n", entry.DecisionReason)
}
PendingApprovalsResponse pending =
client.getPendingPlanApprovals(20, planId);
for (PendingApproval entry : pending.getPendingApprovals()) {
System.out.println("Plan: " + entry.getPlanId());
System.out.println("Step: " + entry.getStepId() + " (" + entry.getStepName() + ")");
System.out.println("Reason: " + entry.getDecisionReason());
}
curl -X GET "http://localhost:8080/api/v1/plans/approvals/pending?plan_id=${PLAN_ID}" \
-H "Authorization: Basic $(echo -n "${CLIENT_ID}:${LICENSE_KEY}" | base64)" \
-H "X-Tenant-ID: ${TENANT_ID}"
Example response:
{
"pending_approvals": [
{
"workflow_id": "wf-map-travel-001",
"workflow_name": "map-confirm-PNR-8QR4WT",
"plan_id": "plan_1776860545_t8slb7jn",
"step_id": "step_3_supervisor-review",
"step_index": 3,
"step_name": "supervisor-review",
"step_type": "tool_call",
"decision": "require_approval",
"decision_reason": "High-value refund requires supervisor review",
"policies_matched": [
{
"policy_id": "f9b22075-7e59-4ba2-a31e-bb6e059ae96b",
"policy_name": "travel-refund-supervisor-gate",
"action": "require_approval",
"risk_level": "high"
}
],
"step_input": {
"customer_id": "CUST-48210",
"booking_reference": "PNR-8QR4WT",
"amount_eur": 1240.00
},
"approval_status": "pending",
"created_at": "2026-04-22T14:00:00Z"
}
],
"count": 1
}
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.
- Python
- TypeScript
- curl
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']}")
// Plan-scoped approve is a direct HTTP call today; the response shape
// matches WCP approve (ApproveStepResponse type).
const auth = Buffer.from(
`${process.env.AXONFLOW_CLIENT_ID}:${process.env.AXONFLOW_CLIENT_SECRET}`
).toString('base64');
const resp = await fetch(
`http://localhost:8080/api/v1/plans/${planId}/steps/${stepId}/approve`,
{
method: 'POST',
headers: {
Authorization: `Basic ${auth}`,
'X-Tenant-ID': process.env.AXONFLOW_CLIENT_ID ?? '',
'X-User-ID': '[email protected]',
'Content-Type': 'application/json',
},
body: JSON.stringify({
comment: 'Approved - valid cancellation per fare rules; certificate reviewed',
}),
}
);
const data = await resp.json();
console.log(`decision=${data.decision} approved_by=${data.approved_by}`);
console.log(`approval_id=${data.approval_id}`);
curl -X POST "http://localhost:8080/api/v1/plans/${PLAN_ID}/steps/${STEP_ID}/approve" \
-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 '{
"comment": "Approved - valid cancellation per fare rules; certificate reviewed"
}'
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:
- Call
/api/v1/workflows/approvals/pendingand 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. - Call the Enterprise-only
/api/v1/hitl/queuesurface. 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 hasplan_idpopulated 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/pendingreturns every MAP-backed pending approval in the tenant withplan_idpopulated on every row. The UI can group byplan_idclient-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_idis 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_idasymmetry. A new field added to approve/reject automatically surfaces on both planes. - An approval pointer leaking to a different customer's plan.
idempotency_keyon step gates (v7.3.0) plus theapproval_idUUID 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_idcannot 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.
Related
- HITL Approval Gates - full API reference including both plane's pending listings
- Human-in-the-Loop - operating model, MAP plan-scoped equivalents, and the Enterprise HITL queue
- Healthcare Prior Auth tutorial - the idempotency-key safety story with WCP
- Payment Agent Retry & Reconciliation tutorial - retry_context and idempotency_key across gate/complete
- AxonFlow v7.4.0 Release Notes - full release narrative covering HITL response parity and the new pending-list endpoint
