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 choice | What it writes |
|---|---|
| Inbox | human_interface: default |
| A webhook record | human_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
| Field | Type | Default | Description |
|---|---|---|---|
agent_type | string | "llm" | Must be "human" |
is_human_powered | boolean | false | Must be true |
human_interface | string | "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_seconds | int | 300 | Timeout in seconds before HUMAN_INPUT_TIMEOUT fires |
communication_type | string | "dual" | "dual" (async — send webhook, return immediately, wait for inbound) or "chain" (blocking — wait for response before returning) |
fallback_on_timeout | boolean | false | Use fallback_response values when timeout occurs |
fallback_response | map | — | 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
| Field | Type | Default | Description |
|---|---|---|---|
connection | string | — | 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_timeout | int | 30 | Timeout in seconds for outbound webhook delivery |
inbound_endpoint | string | — | The webhook path for human responses (e.g. /webhook/greet_user) |
max_retries | int | 3 | Max retry attempts for outbound webhook delivery |
retry_delay | int | 5 | Base delay in seconds between retries (exponential backoff) |
operators | map | {} | 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 pageBackwards-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
| Event | When |
|---|---|
HUMAN_INPUT_REQUESTED | Outbound webhook sent |
HUMAN_INPUT_RECEIVED | Human provides a response via inbound webhook |
HUMAN_INPUT_TIMEOUT | Human does not respond within timeout |
WORKFLOW_PAUSED | Flow paused for human input |
WORKFLOW_RESUMED | Flow resumed after human responds |
HUMAN_ESCALATION | Manager 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
| Field | Type | Description |
|---|---|---|
connection | string | Names a connection in the vault. Preferred over any inline credential. |
listen_channels | array | Channel IDs to accept inbound from. Empty means all. |
post_channel | string | Default target when a message names none |
operators | object | Logical operator id → this channel's user handle, so a step can address a specific person |
operators_only | boolean | Refuse anyone not listed in operators. Off by default — a customer-facing seat must accept strangers. Turn it on for a staff-only seat. |
socket_mode | boolean | Slack 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_responsevalues 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:
- Standard email headers —
In-Reply-ToandReferencesare the spec-defined way clients chain replies. The adapter tries these first. - Plus-addressing reply token — outbound mail is sent with
Reply-To: support+THREAD123@inbound.example.com. When the user replies, the local partsupport+THREAD123carries the thread ID even when the client strips reply headers. - Subject-token fallback — if
subject_prefixis 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 thanno - Messages with
Precedence:set tobulk,list,junk, orauto_reply(matches any token in a multi-value header likePrecedence: 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
- Configure an inbound route in Mailgun:
match_recipient(".*@inbound.example.com")→ forward tohttps://your-leafmesh.example.com/channels/email/{agent_name}/mailgun/inbound. - Get the HTTP webhook signing key from Mailgun →
Settings → Webhooks → HTTP webhook signing key. Save it as thesigning_secretfield of the email record (Infrastructure → Workspace → Email). - Inbound requests are verified using Mailgun's signing scheme; stale / replayed requests are rejected.
- SMTP credentials come from
Sending → Domain settings → SMTP credentials; save them on the email record. Use port 587 with STARTTLS.
SendGrid
- Configure Inbound Parse: domain
inbound.example.com→ POST URLhttps://your-leafmesh.example.com/channels/email/{agent_name}/sendgrid/inbound. Enable Check incoming emails for spam but do not enable Send Raw. - 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. - Outbound: use SendGrid SMTP relay (
smtp.sendgrid.net:587) with API key as the SMTP password andapikeyas the username, saved on the email record.
Postmark
- Create an Inbound stream with the inbound domain. Postmark POSTs to
https://your-leafmesh.example.com/channels/email/{agent_name}/postmark/inbound. - Verification is HTTP Basic Auth — set the username/password under stream settings and configure the same on your reverse proxy or set
signing_secretto the Basic Auth shared secret. Optionally restrict the inbound webhook by IP allowlist (Postmark publishes the source IPs). - Postmark's
MailboxHashfield carries the plus-addressing token (support+THREAD123@inbound.example.com→MailboxHash="THREAD123"). Threading uses this even when DNS routing strips the local part. - 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: 25The 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
| Provider | Inbound URL |
|---|---|
mailgun | POST /channels/email/{agent_name}/mailgun/inbound |
sendgrid | POST /channels/email/{agent_name}/sendgrid/inbound |
postmark | POST /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 somethingThe model is used for three things:
- Filling the fields your routes read. In the example, it decides whether
the reply is a
refundor aquestion— 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
- Agent Types — All four agent types
- HITL Workflows — Tested approval patterns
- UI Integration — Building review interfaces
- Manager — Escalation — Multi-target escalation
LeafMesh — Human judgment as a first-class mesh node