Skip to content

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 true is 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.