Human-in-the-Loop Agent
565
An A2A agent that routes questions from automated agents to a human operator via messaging platforms. When an execution agent (browser, terminal, Android, etc.) encounters a situation it cannot resolve on its own — a CAPTCHA, a 2FA code, an ambiguous decision — it calls this agent and waits for a human reply.
Supports four notification channels (any combination):
Execution agent hil_agent Human operator
────────────── ───────── ──────────────
ask_human {question, screenshot}
→ A2A message/stream ────────▶ parse message
create Future
notify all channels ──────▶ Telegram / Discord / Slack / WhatsApp
poll (reminder every 10 min)
◀──────── operator taps Done / Skip / reply
Future resolved
◀─────────────────── completed (answer text)
resume graph with answer
ask_human with an optional screenshot and a question.hil_agent registers a pending asyncio.Future keyed by task ID, then broadcasts to every configured channel.HIL_HARD_TIMEOUT (default 24 h), the task fails with TimeoutError.The message format from execution agents is:
data:image/jpeg;base64,<base64_screenshot>\n\n<question text>
If no screenshot is attached, the message is plain text.
app/
channels/
base.py Channel ABC, HumanRequest dataclass, OnResponseCallback type
telegram.py TelegramChannel — aiogram, long-polling
discord.py DiscordChannel — discord.py, long-polling
slack.py SlackChannel — slack-bolt async, webhook (POST /slack/events)
whatsapp.py WhatsAppChannel — httpx direct Meta Cloud API, webhook (GET/POST /webhook/whatsapp)
dispatcher.py Dispatcher — pending Future registry + reminder loop
server/
config.py Settings (pydantic-settings, all channel credentials)
a2a.py HumanRelayExecutor + _parse_message + setup_a2a_routes()
main.py FastAPI app with lifespan (builds channels, starts dispatcher)
__main__.py Entry: python3 -m app.server
tests/
test_dispatcher.py Unit tests — Dispatcher, reminder loop, timeout logic
test_a2a.py Unit tests for _parse_message + integration + ASGI HITL flow tests
All settings are read from environment variables (or a .env file). At least one channel must be configured, otherwise the agent starts but cannot reach any human.
| Variable | Default | Description |
|---|---|---|
PORT | 8090 | HTTP listen port (plain HTTP — deploy behind a TLS proxy or within a private network) |
API_KEY | `` | Bearer token for A2A endpoint auth; empty = no auth |
AGENT_URL | http://localhost:8090/ | Public URL published in the agent card |
HIL_REMINDER_INTERVAL | 600 | Seconds between reminders (default 10 min) |
HIL_HARD_TIMEOUT | 86400 | Seconds before giving up (default 24 h) |
Create a bot via @BotFather, add it to a group or use a direct chat, then get the chat ID with getUpdates.
| Variable | Description |
|---|---|
TELEGRAM_BOT_TOKEN | Bot token from BotFather |
TELEGRAM_CHAT_ID | Chat or group ID where notifications are sent |
Create a bot at discord.com/developers, grant it Send Messages + Read Message History permissions, invite it to your server, then copy the target channel ID.
| Variable | Description |
|---|---|
DISCORD_BOT_TOKEN | Bot token |
DISCORD_CHANNEL_ID | Target channel (numeric ID) |
Create a Slack app with chat:write and chat:write.public bot token scopes. Enable Interactivity and set the request URL to https://<your-host>/slack/events.
| Variable | Description |
|---|---|
SLACK_BOT_TOKEN | xoxb-... bot token |
SLACK_CHANNEL_ID | Channel to post in (e.g. C0123456789) |
SLACK_SIGNING_SECRET | App signing secret (for request verification) |
Requires a Meta Business account with a WhatsApp Cloud API app. Set the webhook URL to https://<your-host>/webhook/whatsapp and subscribe to messages.
| Variable | Description |
|---|---|
WHATSAPP_PHONE_NUMBER_ID | Phone number ID from Meta |
WHATSAPP_ACCESS_TOKEN | Permanent or temporary access token |
WHATSAPP_RECIPIENT_PHONE | Operator's phone number in E.164 format (e.g. +12125551234) |
WHATSAPP_VERIFY_TOKEN | Arbitrary string for webhook challenge verification |
cd apps/hil_agent
cp .env.example .env
# Fill in at least one channel's credentials in .env
docker compose up
Build from this folder. The shared a2a_agent workspace package (living outside
this directory at ../../packages/a2a_agent) is provided to the build via a
named build context:
cd apps/hil_agent
docker build \
--build-context a2a_pkg=../../packages/a2a_agent \
-f Dockerfile \
-t hil_agent \
.
docker run -d \
-p 8090:8090 \
-e TELEGRAM_BOT_TOKEN=... \
-e TELEGRAM_CHAT_ID=... \
-e API_KEY=mysecret \
hil_agent
# Install the a2a_agent workspace package first
pip install -e ../../packages/a2a_agent
pip install -e .
TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=... python3 -m app.server
The agent exposes a standard Google A2A JSON-RPC endpoint at http://<host>:8090/.
Agent card: GET http://<host>:8090/.well-known/agent-card.json
Skill: ask_human
Send a message/send or message/stream request. The message text can be:
Plain question (no screenshot):
What should I enter in the CAPTCHA field?
Question with screenshot (data URL prefix + double newline separator):
data:image/jpeg;base64,/9j/4AAQSkZJRgAB...
The page is asking for a CAPTCHA. What do I type?
message/stream request{
"jsonrpc": "2.0",
"id": "req-1",
"method": "message/stream",
"params": {
"message": {
"role": "user",
"messageId": "msg-1",
"parts": [
{"kind": "text", "text": "2FA code requested for account [email protected] — please check your phone"}
]
}
}
}
SSE events while waiting:
data: {"jsonrpc":"2.0","id":"req-1","result":{"kind":"status-update","taskId":"task-abc","status":{"state":"working","message":{"parts":[{"kind":"text","text":"Notification sent to all configured channels"}]}},"final":false}}
data: {"jsonrpc":"2.0","id":"req-1","result":{"kind":"status-update","taskId":"task-abc","status":{"state":"working","message":{"parts":[{"kind":"text","text":"Reminder #1 sent"}]}},"final":false}}
Final event (after human responds):
data: {"jsonrpc":"2.0","id":"req-1","result":{"kind":"status-update","taskId":"task-abc","status":{"state":"completed"},"final":true}}
ask_human from an execution agentAll execution agents (chrome, terminal, android, etc.) support the ask_human action natively. Set two environment variables on the execution agent:
HUMAN_RELAY_AGENT_URL=http://hil-agent:8090/
HUMAN_RELAY_AGENT_API_KEY=mysecret # only if API_KEY is set on hil_agent
The execution agent will automatically call hil_agent when it emits ask_human and pause the graph until the human responds.
Each channel posts a message with three reply options:
| Button | Meaning sent back to agent |
|---|---|
| Done | "done" |
| Skip | "skip" |
| Enter answer… | Opens a text input; sends the typed text |
For Slack and WhatsApp, button IDs are shortened to 16–18 hex characters (platform limits) and resolved back to the full task ID internally.
| Condition | Action |
|---|---|
No response after HIL_REMINDER_INTERVAL (default 10 min) | Resend notification to all channels with "Reminder #N" |
No response after HIL_HARD_TIMEOUT (default 24 h) | Future set to TimeoutError; task fails; caller receives error |
| Human responds on any channel | Future resolved immediately; all further reminders cancelled |
| Second response for the same task | Ignored (first-response-wins) |
cd apps/hil_agent
pip install -e ".[test]"
# Unit tests only (no server needed)
pytest tests/test_dispatcher.py tests/test_a2a.py::TestParseMessage -v
# Unit tests + in-process ASGI HITL flow tests (no server needed, all deps required)
pytest -v
# Integration tests against a running server
AGENT_HOST=localhost AGENT_PORT=8090 pytest -v -m integration
The agent image exposes an MCP (Model Context Protocol) server on the same port as A2A at /mcp. Any MCP client — Claude Desktop, Cursor, Windsurf, ChatGPT Connectors, OpenAI Agents SDK — can list and invoke these tools using the pod's API_KEY as Bearer.
In production, Core proxies https://hil-agent.agents.forfetch.ai/mcp → the worker pod's /mcp.
| Tool (skill_id) | Description |
|---|---|
ask_human | Route a question to a human operator and wait for their response. Accepts an optional data:image/jpeg;base64,... prefix to attach a screenshot. |
Input schemas are authored in apps/agents_mcp/app/seed.py. Skills without an explicit schema advertise a single {task: str} freeform parameter.
Content type
Image
Digest
sha256:fb3eca56f…
Size
79.8 MB
Last updated
5 months ago
docker pull superbizon007/hil-agent