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 - Professional and above (Evaluation+ from v7.4.0 until HITL became Enterprise-only; 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 a Professional or higher license; HITL approval queues are not part of Evaluation. 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.
Licensing: getaxonflow.com/pricing.
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
(confirm mode holds every step
for approval; no policy is involved)
│
▼
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 (before v11.1.0, with
AXONFLOW_HITL_ENABLED=true; from v11.1.0 the setting is ignored) - 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+
Step 0 - Decide what holds the supervisor-review step
This tutorial runs the plan in confirm mode, and in confirm mode every plan step is held for approval by the execution mode itself: each step's gate is forced to require_approval without asking the policy engine. No policy is needed, and none is created here. The legacy /api/v1/dynamic-policies route this step used before v11 cannot create a hold anyway, and a tenant policy's require_approval action does not hold a step on v11.
A held step in confirm mode reports the reason Gate override: execution mode requires require_approval, not a policy.
On a multi-agent plan, only confirm and step mode make a step wait for a person (confirm mode holds every step; step mode runs the first step and holds each later one). An approval requirement that matches a step of a plan run in any other mode does not pause it. From v11.1.0 the step is withheld on every deployment: the call answers HTTP 403 with code approval_requires_durable_record, and nothing after it runs. A requirement selects steps by the action their step type presents as (connector-call, function-call and api-call as tool.call; llm-call as llm.completion), by who is calling, and, from v11.1.0, by the step's name (args.context.step__name, for example supervisor-review; see the step-name recipe). A multi-agent step carries no tool name. It cannot select a step by a value in its input, so the €500 threshold stays in your application.
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/v9"
)
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": "Gate override: execution mode requires require_approval",
"policies_matched": [],
"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"],
},
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 ?? '',
'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: Gate override: execution mode requires require_approval",
"approval_status": "approved",
"approval_id": "75a2e087-b9ef-5645-8662-5bd2147c47e0",
"approved_at": "2026-04-22T14:05:00Z",
"policies_matched": [],
"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
- Human-in-the-Loop - full API reference including both planes' pending listings, MAP plan-scoped equivalents, the operating model, 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
