HITL Workflow
%%{init: {'look': 'handDrawn', 'theme': 'base', 'themeVariables': {'primaryColor': '#eef2ff', 'primaryBorderColor': '#1e40af', 'primaryTextColor': '#1e293b', 'lineColor': '#1e3a8a', 'edgeLabelBackground': '#ffffff', 'clusterBkg': '#fbfcff', 'clusterBorder': '#2563eb', 'fontFamily': '-apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif', 'fontSize': '15px'}, 'flowchart': {'nodeSpacing': 50, 'rankSpacing': 58, 'padding': 14, 'htmlLabels': true, 'curve': 'basis'}}}%%
flowchart LR
C(["Conversation"]) --> D("Draft a reply")
D --> H[/"A human reads it<br/>and decides"/]
H == "only if approved" ==> S("Send it, exactly once")Outcome: a model drafts a customer-facing action, the workflow pauses durably for a human decision, and only an explicit approval reaches the send — with rejection and expiry recorded as distinct outcomes rather than silently treated as consent.
Absence of approval is not approval
The failure mode this recipe is built against is a workflow that reads ${human_decision.output.approved} directly and routes on it. If the reviewer completes the task without that field, or the field arrives as the string "false", or the task times out, a truthiness check can let the action through. The default must be refusal.
normalize_decision exists for exactly that. It coerces the human's payload into a strict shape before any routing happens:
{approved: ((.decision.approved // false) == true), approver: (.decision.approver // "unknown"), note: (.decision.note // "")}
An absent field becomes false. A non-boolean becomes false. Only a literal true is approval. The SWITCH then routes on that normalized value, never on the raw human output.
The three outcomes are all durable and all distinguishable in the output:
| Outcome | delivery.status |
|---|---|
| Reviewer approved | sent, with the idempotency key used |
| Reviewer declined | withheld_by_reviewer, with their note |
| Nobody decided in time | Workflow times out; approval.status stays pending |
Prerequisites
An OpenAI integration, and an endpoint to deliver to. send_approved_action posts to the deliveryUrl you pass in, with an Idempotency-Key header carrying actionKey — point it at your own service, which must honor that header. https://httpbin.org/post works for a trial run and echoes back exactly what was sent.
The HUMAN task carries a 20-hour timeout inside an 86,400-second (24-hour) workflow, which is what makes a real review queue viable. A one-hour timeout on an approval that needs a human awake in another timezone will expire every night.
Runnable definition
Save this as hitl-approval.json:
{
"name": "hitl_approved_action",
"description": "A model drafts a customer-facing action, a human decides, and only an explicit approval reaches the idempotent send. Rejection and expiry are distinct, recorded outcomes \u2014 neither is treated as consent.",
"version": 1,
"schemaVersion": 2,
"timeoutSeconds": 86400,
"timeoutPolicy": "TIME_OUT_WF",
"inputParameters": [
"customerId",
"conversation",
"actionKey",
"deliveryUrl"
],
"variables": {
"approval": {
"status": "not_requested",
"approver": "",
"decidedAt": ""
},
"delivery": {
"status": "not_attempted"
}
},
"tasks": [
{
"name": "draft_customer_action",
"taskReferenceName": "draft",
"type": "LLM_CHAT_COMPLETE",
"inputParameters": {
"llmProvider": "openai",
"model": "gpt-4o-mini",
"messages": [
{
"role": "system",
"message": "Draft a customer-facing resolution for review. Return JSON: {\"summary\": string, \"proposedMessage\": string, \"riskFlags\": [string]}. Never promise a refund amount, credit, or deadline that is not stated in the conversation. Put anything you are unsure about in riskFlags."
},
{
"role": "user",
"message": "Customer: ${workflow.input.customerId}\nConversation: ${workflow.input.conversation}"
}
],
"temperature": 0.2,
"maxTokens": 800,
"jsonOutput": true
}
},
{
"name": "request_approval",
"taskReferenceName": "request_approval",
"type": "SET_VARIABLE",
"inputParameters": {
"approval": {
"status": "pending",
"approver": "",
"decidedAt": ""
}
}
},
{
"name": "await_human_decision",
"taskReferenceName": "human_decision",
"type": "HUMAN",
"asyncComplete": true
},
{
"name": "normalize_decision",
"taskReferenceName": "normalize_decision",
"type": "JSON_JQ_TRANSFORM",
"inputParameters": {
"decision": "${human_decision.output}",
"queryExpression": "{approved: ((.decision.approved // false) == true), approver: (.decision.approver // \"unknown\"), note: (.decision.note // \"\")}"
}
},
{
"name": "record_decision",
"taskReferenceName": "record_decision",
"type": "SET_VARIABLE",
"inputParameters": {
"approval": {
"status": "decided",
"approved": "${normalize_decision.output.result.approved}",
"approver": "${normalize_decision.output.result.approver}",
"note": "${normalize_decision.output.result.note}"
}
}
},
{
"name": "route_on_decision",
"taskReferenceName": "route_decision",
"type": "SWITCH",
"evaluatorType": "value-param",
"expression": "approved",
"inputParameters": {
"approved": "${normalize_decision.output.result.approved}"
},
"decisionCases": {
"true": [
{
"name": "send_approved_action",
"taskReferenceName": "send_action",
"type": "HTTP",
"inputParameters": {
"http_request": {
"uri": "${workflow.input.deliveryUrl}",
"method": "POST",
"headers": {
"Idempotency-Key": "${workflow.input.actionKey}"
},
"body": {
"customerId": "${workflow.input.customerId}",
"message": "${draft.output.result.proposedMessage}",
"approvedBy": "${normalize_decision.output.result.approver}"
},
"connectionTimeOut": 5000,
"readTimeOut": 15000
}
}
},
{
"name": "record_delivery",
"taskReferenceName": "record_delivery",
"type": "SET_VARIABLE",
"inputParameters": {
"delivery": {
"status": "sent",
"idempotencyKey": "${workflow.input.actionKey}"
}
}
}
],
"false": [
{
"name": "record_rejection",
"taskReferenceName": "record_rejection",
"type": "SET_VARIABLE",
"inputParameters": {
"delivery": {
"status": "withheld_by_reviewer",
"note": "${normalize_decision.output.result.note}"
}
}
}
]
},
"defaultCase": []
}
],
"outputParameters": {
"draft": "${draft.output.result}",
"approval": "${workflow.variables.approval}",
"delivery": "${workflow.variables.delivery}"
}
}
Register and run
conductor workflow create hitl-approval.json
conductor workflow start -w hitl_approved_action -i '{"customerId":"C-123","conversation":"Customer reports being charged twice for a returned order. Order 8891, two charges of $49.00 on 12 July.","actionKey":"refund-note-C-123-0001","deliveryUrl":"https://httpbin.org/post"}'
Open Executions in the Conductor UI and select the new execution to review the task graph, and each task's inputs and outputs.
The run pauses at human_decision. Review the draft and its riskFlags first, then complete the task. In the UI you can complete it from the execution view; on OSS Conductor the equivalent call is (replace the workflow ID):
curl -X POST '<YOUR-CLUSTER-URL>/api/tasks/WORKFLOW_ID/human_decision/COMPLETED' \
-H 'Content-Type: application/json' \
-d '{"approved":true,"approver":"support-oncall","note":"Verified duplicate charge in the ledger."}'
Three completions worth trying, because all three must refuse to send:
{"approved":false,"approver":"support-oncall","note":"Amount not verified."} # explicit rejection
{} # reviewer sent nothing
{"approved":"false","approver":"bot"} # string, not boolean
Each one completes the workflow successfully with delivery.status: withheld_by_reviewer and no send_action task in the execution at all. Completing successfully while sending nothing is the correct outcome, not a failure.
Production notes
- Approve exactly what ships. Don't re-run the model after approval, or the human approved something else.
- The idempotency key comes from the caller. Generate it inside the workflow and a retry becomes a second message.
- Anything that isn't a literal
trueis a no. Missing fields and the string"false"both withhold. - Record who approved, and when. For regulated work, add the policy version and a digest of what they saw.
- Constrain the draft, not just the review. A reviewer clearing twenty drafts an hour won't catch an invented refund amount.
- Redact before prompting. Strip payment details and pass attachments by reference.