Human Agents

Human Agents

Human agents are full mesh nodes that delegate execution to a real person via webhook. The mesh treats human responses identically to LLM responses — they have yields, can_call rules, and condition evaluation.

Configuration

agents: approver: name: "approver" agent_type: "human" is_human_powered: true human_interface: "webhook" human_timeout_seconds: 300 communication_type: "dual" webhook_config: connection: "review-dashboard" # the address, headers and inbound token live in the vault outbound_timeout: 30 inbound_endpoint: "/webhook/process_request" max_retries: 3 retry_delay: 5 yields: decision: "string" notes: "string" can_call: - agent: "executor" condition: "decision == 'approved'" - agent: "rejection_handler" condition: "decision == 'rejected'"

The webhook's address, its header and query rows, and the inbound_token secret are an Add custom record on Studio's Infrastructure page with service type Outbound webhook; connection: names it. Writing the URL, headers or token inline is refused at load. See Connections & the Infra Page.

The simplest seat needs no webhook at all (2.4.486+). agent_type: human with nothing else set is an Inbox seat: its requests appear in Studio's Inbox and work out of the box. Set human_interface: api only when your own code answers the seat (through register_human_agent). A seat left as api that no code answers is answered in the Inbox, and start-up says so by name. Before 2.4.486, such a seat failed on its first question ("No human interface handler set"); on those versions, set human_interface: default for an Inbox seat.

agents: approver: name: "approver" agent_type: "human" human_interface: "default" # the Inbox (may be left out from 2.4.486) yields: decision: "string"

Reaching a Person

2.4.486+. In Studio, how a seat reaches its person is one choice from one list. There is no new YAML key: the choice writes the shapes that already exist, and existing configs load unchanged. Nothing is refused or migrated.

Studio choiceWhat it writes
Inboxhuman_interface: default
A webhook recordhuman_interface: webhook + webhook_config: {connection: <record>}
A Slack, Teams, Telegram, Zoho Cliq or Email sign-in (WhatsApp and Discord coming)human_interface: webhook + channels: {<channel>: {connection: <record>, …seat fields}} (email also needs from_address)

human_interface: api (your own code answers, through register_human_agent) stays available as an advanced option. Left out, human_interface is api; from 2.4.486 a seat with no handler registered is answered in the Inbox.

How It Works

Human agents use a single unified webhook endpoint: POST /webhook/{entry_point}. The same URL handles both new tasks and human responses — routing is automatic based on whether the body contains a session_id matching a paused workflow.

Two Flows, One Endpoint

Flow 1 — External system starts a new task:

Slack/Zapier → POST /webhook/greet_user
Body: {"message": "Hello, I need help"}

LeafMesh: no session_id → looks up entry point "greet_user" → starts workflow
Response: {"status": "accepted", "session_id": "wh_greet_user_..."}

Flow 2 — Human responds to a paused workflow:

Earlier: mesh routed to human agent → sent outbound webhook
         with session_id and context → mesh PAUSED

Now: Human decides and sends response
POST /webhook/greet_user
Header: X-LeafMesh-Signature: sha256=<HMAC-SHA256>
Body: {"session_id": "abc-123", "decision": "approved", "notes": "Looks good"}

LeafMesh: session_id matches paused session → RESUMES workflow
Response: {"status": "resumed", "session_id": "abc-123", "agent": "approver"}

Step-by-Step (Mid-Flow Human Review)

LLM Agent (analyzer, processor, etc.)
    │ can_call → human agent
    ▼
Human Agent triggered
    │
    ├── 1. Outbound webhook POST to the record's address
    │      Payload: session_id, input_data, conversation_history, timeout
    │
    ├── 2. Flow PAUSES — awaiting the human's response
    │      (API returns the upstream agent's result immediately)
    │
    ├── 3. Human reviews data in your system (dashboard, Slack, email)
    │
    ├── 4. Human responds: POST /webhook/{entry_point}
    │      Header: X-LeafMesh-Signature: sha256=<HMAC>
    │      Body: {"session_id": "...", "decision": "approved"}
    │
    ├── 5. LeafMesh verifies HMAC → finds pending session → resumes
    │
    ├── 6. can_call evaluated against human's response
    │      decision == 'approved' → executor
    │      decision == 'rejected' → rejection_handler
    │
    └── 7. If no response within timeout → HUMAN_INPUT_TIMEOUT event

Configuration Fields

FieldTypeDefaultDescription
agent_typestring"llm"Must be "human"
is_human_poweredbooleanfalseMust be true
human_interfacestring"api"Interface type: "default" (Inbox), "api", "webhook", "custom". From 2.4.486, an "api" seat with no handler registered through register_human_agent is answered in the Inbox
human_timeout_secondsint300Timeout in seconds before HUMAN_INPUT_TIMEOUT fires
communication_typestring"dual""dual" (async — send webhook, return immediately, wait for inbound) or "chain" (blocking — wait for response before returning)
fallback_on_timeoutbooleanfalseUse fallback_response values when timeout occurs
fallback_responsemap—Default response values on timeout (e.g. decision: "timeout")

Scaling: scaling.copies on a human seat is refused at load (from 2.4.485), with an error naming the seat: it would ask the same person the same thing several times. It can use scaling.runs.max to cap how many of its requests are open at once, counted across all pods. See Agent Configuration → scaling.

Webhook Config Fields

FieldTypeDefaultDescription
connectionstring—Names an Outbound webhook record (Infrastructure page → Add custom). The record holds the address the review request is POSTed to (its base URL), the header and query rows, and the inbound_token secret that authenticates inbound responses. outbound_url, outbound_headers and inbound_auth_token written inline are refused at load.
outbound_timeoutint30Timeout in seconds for outbound webhook delivery
inbound_endpointstring—The webhook path for human responses (e.g. /webhook/greet_user)
max_retriesint3Max retry attempts for outbound webhook delivery
retry_delayint5Base delay in seconds between retries (exponential backoff)
operatorsmap{}Operator ID → recipient for this webhook

Outbound Webhook Payload

When the flow reaches a human agent, LeafMesh sends this payload to the webhook record's address:

{ "agent_name": "approver", "session_id": "fa44356e-66dc-4cb6-8dc9-be7512c78a9f", "timestamp": "2026-03-31T15:48:06.123456", "input_data": { "summary": "Q1 budget analysis complete", "flagged_items": ["marketing_budget"], "_agent_name": "analyzer" }, "context": { "session_id": "fa44356e-...", "agent_name": "approver", "agent_type": "human" }, "conversation_history": [ {"role": "user", "content": "Review Q1 budget", "agent": null}, {"role": "assistant", "content": "{...}", "agent": "analyzer"} ], "response_endpoint": "/webhook/process_request", "timeout_seconds": 300 }

Inbound Response Format

The human's response must include session_id. All other fields are used for can_call condition evaluation:

{ "session_id": "fa44356e-66dc-4cb6-8dc9-be7512c78a9f", "decision": "approved", "notes": "Budget approved with note about 12% marketing increase" }

The response must be signed with HMAC-SHA256:

X-LeafMesh-Signature: sha256=<HMAC-SHA256 hex digest of request body>

Inbound webhook requests are signed with LEAFMESH_WEBHOOK_SECRET from the mesh's .env. From 2.4.507 the mesh never derives it; without it, signed webhooks answer 503.

Conversation History in the Payload

Human agents have always shipped the last few conversation turns in the webhook body so the human reviewer has context. Pre-v2.2.1 this was hardcoded to 5 turns. From v2.2.1 onward it's configurable per agent:

agents: client: name: "client" agent_type: "human" receive_conversation_history: true history_limit: 30 # default 20, range 1-200 webhook_config: connection: "hitl-webhook" # an Outbound webhook record on the Infrastructure page

Backwards-compatibility note: Human agents that have NOT set receive_conversation_history: true continue to use the legacy 5-turn cap — your existing HITL integrations keep working unchanged. Flip the flag to take advantage of the new history_limit range (1–200).

Both the built-in HITL queue (the in-app Studio panel) and outbound webhook payloads honour the same cap. The Studio "Inputs & Outputs" panel and Playground inspector expose a single toggle for it.

HITL Event Lifecycle

EventWhen
HUMAN_INPUT_REQUESTEDOutbound webhook sent
HUMAN_INPUT_RECEIVEDHuman provides a response via inbound webhook
HUMAN_INPUT_TIMEOUTHuman does not respond within timeout
WORKFLOW_PAUSEDFlow paused for human input
WORKFLOW_RESUMEDFlow resumed after human responds
HUMAN_ESCALATIONManager escalates to human intervention

The Manager subscribes to all HITL events. On timeout, it takes configured actions: retry, escalate, fallback, or stop.

Native Channel Adapters

Beyond webhooks, human agents support native channel adapters for messaging platforms. Adapters handle inbound routing, outbound notifications, and session mapping automatically.

Supported today: Slack, Microsoft Teams, Telegram, Zoho Cliq, Email and the hosted Inbox. Discord and WhatsApp are coming.

Slack Adapter

agents: human_support: name: "human_support" agent_type: "human" is_human_powered: true human_timeout_seconds: 600 channels: slack: connection: "slack-workspace" # made under Infrastructure → Messaging → Slack listen_channels: ["C0123456789"] post_channel: "C0123456789"

connection: names a credential held in your mesh's own encrypted vault. Nothing secret goes in config — see Connections & the Infra page. A literal token, or an ${ENV_VAR} reference in its place, is refused at load; the connection name may come from the environment (connection: "${SLACK_CONNECTION:slack-workspace}"). Channel records are made under Infrastructure → Messaging (Slack, Microsoft Teams, Telegram, Zoho Cliq; Discord and WhatsApp coming); email under Infrastructure → Workspace → Email.

Channel Configuration

FieldTypeDescription
connectionstringNames a connection in the vault. Preferred over any inline credential.
listen_channelsarrayChannel IDs to accept inbound from. Empty means all.
post_channelstringDefault target when a message names none
operatorsobjectLogical operator id → this channel's user handle, so a step can address a specific person
operators_onlybooleanRefuse anyone not listed in operators. Off by default — a customer-facing seat must accept strangers. Turn it on for a staff-only seat.
socket_modebooleanSlack only. Receives events over an outbound WebSocket, so no public URL is needed. Turns itself on when the connection holds an app-level (xapp-) token.

How Inbound Messages Are Routed

Two questions, in this order: is this for us, and which conversation is it.

1. Is the message addressed to the mesh?

A DIRECT message (DM, or mail to the mesh's address)
    └── always ours — no mention needed

A message in a SHARED ROOM
    ├── mentions the mesh, names one of its agents, or is a slash command → ours
    ├── posted inside a thread the mesh opened                            → ours
    └── anything else                                                     → IGNORED

A bare message in a room is people talking to each other. Having spoken to the mesh before does not make later messages its business — otherwise a "yes, go ahead" aimed at a colleague gets read as an answer to a pending request.

The mesh also never answers its own messages, on any channel.

2. Which conversation does it belong to?

Addressed the mesh directly (@mention / named agent / slash command)
    └── a NEW conversation, even if a question is open
        (nobody @-mentions a bot to answer its question — you just type)

A reply inside a thread
    ├── something pending → answers THAT message, in the same session
    └── nothing pending   → runs the flow again, in the SAME session

A new email (no In-Reply-To, no reply token)
    └── a new conversation. A reply stays in its own.

Threads exist on Slack, Teams and Email. Telegram and Cliq have none, so each person has one continuous conversation there — and a question the mesh asks in a Cliq room can only be answered by naming it, or in a DM.

operators_only: true adds a prior gate: anyone not in operators is refused before any of this, including in a DM.

Using Channels with Escalation Targets

manager: escalation: targets: - type: "channel" provider: "slack" channel_id: "C0123456789" message_template: "Escalation: {{issue}} for customer {{customer_id}}"

Timeout Handling

When the human doesn't respond within human_timeout_seconds:

agents: approver: name: "approver" agent_type: "human" human_timeout_seconds: 300 fallback_on_timeout: true fallback_response: decision: "timeout_default" message: "Request timed out — auto-escalated"

Timeout strategies:

  • Retry: Send the request again to the same reviewer
  • Fallback: Use fallback_response values and continue the chain
  • Escalate: Route to a different human or Manager escalation target
  • Stop: End the chain

When the person is told their reply did not come in time ("No reply within N seconds …"), that notice is posted in the thread of the question it is about — on Slack, Teams and Telegram as on email and the Inbox — not as a new message beside it.

Email Channel

Email is a first-class channel — same model as Slack/Discord/Teams. Inbound mail arrives via a webhook from your email service provider, gets parsed + verified + threaded to a session, and is routed to the human agent. Outbound replies are sent over SMTP.

When to Use

  • Customer support inboxes (support@yourcompany.com)
  • Approval requests where the reviewer prefers email over a dashboard
  • Any case where the counterparty cannot install an app or sign into a portal

Configuration

Add channels.email to a human agent. The full field reference is at Email Channel — ChannelConfig.

agents: email_responder: name: "email_responder" agent_type: "human" is_human_powered: true yields: reply: "string" decision: "string" channels: email: provider: "mailgun" # mailgun | sendgrid | postmark region: "us" connection: "email-support" # Infrastructure → Workspace → Email: holds the SMTP host, username and password inbound_domain: "inbound.example.com" from_address: "support@example.com" subject_prefix: "[#TICKET-{thread_id}]"

A corrected password applies live (2.4.492+). When the email record's SMTP/IMAP username and password, or the provider API key (Mailgun, SendGrid, Postmark), is corrected or rotated in the vault, the running channel uses the new value with no restart. A value written directly in the channel's config is never replaced by the vault.

How Threading Works

The adapter threads inbound replies to the same session as the original conversation using two complementary mechanisms:

  1. Standard email headers — In-Reply-To and References are the spec-defined way clients chain replies. The adapter tries these first.
  2. Plus-addressing reply token — outbound mail is sent with Reply-To: support+THREAD123@inbound.example.com. When the user replies, the local part support+THREAD123 carries the thread ID even when the client strips reply headers.
  3. Subject-token fallback — if subject_prefix is set (e.g. "[#TICKET-{thread_id}]"), the adapter parses the thread ID out of the subject as a last-resort match.

Choose the strategy with the threading field:

  • references_first (default) — try headers first, fall back to plus-addressing, then subject token.
  • reply_to_token — skip headers, rely solely on plus-addressing. Use this when you don't trust upstream MTAs to preserve headers.

The thread token embedded in Reply-To is sanitized: \r, \n, \x00, and @ are stripped to prevent header injection.

Auto-Reply Filtering

auto_reply_drop: true (default) silently drops:

  • Messages with Auto-Submitted: header set to anything other than no
  • Messages with Precedence: set to bulk, list, junk, or auto_reply (matches any token in a multi-value header like Precedence: bulk, list)
  • Senders matching common no-reply patterns (noreply@, no-reply@, donotreply@, etc.)

This prevents out-of-office replies, mailing list bounces, and vacation responders from creating noise sessions.

Provider Setup

Mailgun

  1. Configure an inbound route in Mailgun: match_recipient(".*@inbound.example.com") → forward to https://your-leafmesh.example.com/channels/email/{agent_name}/mailgun/inbound.
  2. Get the HTTP webhook signing key from Mailgun → Settings → Webhooks → HTTP webhook signing key. Save it as the signing_secret field of the email record (Infrastructure → Workspace → Email).
  3. Inbound requests are verified using Mailgun's signing scheme; stale / replayed requests are rejected.
  4. SMTP credentials come from Sending → Domain settings → SMTP credentials; save them on the email record. Use port 587 with STARTTLS.

SendGrid

  1. Configure Inbound Parse: domain inbound.example.com → POST URL https://your-leafmesh.example.com/channels/email/{agent_name}/sendgrid/inbound. Enable Check incoming emails for spam but do not enable Send Raw.
  2. Generate an ECDSA P-256 verification key (Settings → Mail Settings → Event Webhook → Signed Event Webhook). Paste the public key (PEM, base64) as the email record's signing_secret.
  3. Outbound: use SendGrid SMTP relay (smtp.sendgrid.net:587) with API key as the SMTP password and apikey as the username, saved on the email record.

Postmark

  1. Create an Inbound stream with the inbound domain. Postmark POSTs to https://your-leafmesh.example.com/channels/email/{agent_name}/postmark/inbound.
  2. Verification is HTTP Basic Auth — set the username/password under stream settings and configure the same on your reverse proxy or set signing_secret to the Basic Auth shared secret. Optionally restrict the inbound webhook by IP allowlist (Postmark publishes the source IPs).
  3. Postmark's MailboxHash field carries the plus-addressing token (support+THREAD123@inbound.example.com → MailboxHash="THREAD123"). Threading uses this even when DNS routing strips the local part.
  4. Outbound: Postmark SMTP (smtp.postmarkapp.com:587) with the server token as both username and password, saved on the email record.

Inbound Attachments (Opt-In)

Attachments are dropped by default. To surface them:

channels: email: # ... attachments: receive: true max_size_mb: 25

The object-storage address (storage_url, e.g. s3://bucket/prefix) is set on the email record (Infrastructure → Workspace → Email), not in YAML. When enabled, attachments are uploaded there and the agent receives presigned URLs in input_data.attachments — never raw bytes. This caps memory pressure and keeps PII out of agent context windows.

Webhook Endpoint Reference

ProviderInbound URL
mailgunPOST /channels/email/{agent_name}/mailgun/inbound
sendgridPOST /channels/email/{agent_name}/sendgrid/inbound
postmarkPOST /channels/email/{agent_name}/postmark/inbound

Request signatures are verified before the body is parsed. Requests that fail verification return 401 and never reach the agent.

Understanding What a Person Meant

A person answers in their own words. "Go ahead", "not this one, it's past the window", a reply in Hindi, a thumbs-up — the mesh never decides what a reply means by looking for particular words. If you give the human agent a model, that model reads the reply:

agents: approver: name: approver agent_type: human human_interface: webhook intent_model: claude-haiku-4-5 can_call: - agent: refunds_agent condition: "intent == 'refund'" description: when the person wants money returned - agent: support_agent condition: "intent == 'question'" description: when the person is asking something

The model is used for three things:

  • Filling the fields your routes read. In the example, it decides whether the reply is a refund or a question — choosing only from the values your conditions name. It never picks the destination itself, and it can never claim a reply came from somewhere it did not.
  • Choosing between routes you left open. When several routes have no condition, it picks the one the reply is meant for, using each route's description, instead of sending the reply to all of them.
  • Learning from the person's decisions. It reads whether the reply decides something, whether it lets the work go ahead, and why — and keeps that as experience.

There is no default model. Without intent_model, the human agent asks the person which agent they meant whenever a reply cannot be routed on its own; for experience, the model of the agent that asked is used, and if that has none, nothing is filed.

At startup the mesh warns you when the setup cannot work as written — a model that will never be used, routes that need a model to read plain replies, or a reply that would fan out to several agents.

When to Use Human Agents

  • Approval workflows (refunds, deployments, access requests)
  • Quality review for high-stakes LLM outputs
  • Escalation endpoints for critical incidents
  • Compliance checkpoints requiring human judgment
  • Any decision that should not be fully automated

Next Steps


LeafMesh — Human judgment as a first-class mesh node

Last updated: 11/10/2026