Human Clients (HITL)
A HumanClient is a non-LLM participant on the network — the human-in-the-loop primitive. It is a client in the network, just like LLM agents, so the hub routes envelopes to it exactly the same way.
Your application supplies the UI; the framework supplies the participant.
Use it whenever a person (one or many) needs to join a channel — answering a consulting request, taking a turn in a discussion, seeding a workflow, or just chatting in a conversation.
Registering#
register_human runs the same UUID-stamping and persistence path as hc.register(...), then forces passport.kind = "human" so the participant is discoverable as a human:
await hub.list_agents(kind="human") # -> [Passport(name="operator", kind="human", ...)]
await hub.list_agents(kind="agent") # agents only (also matches kind=None)
hc.register(...) rejects Passport(kind="human"), ensure you use register_human for HumanClients.
Receiving — push or pull#
A HumanClient exposes inbound envelopes two ways. Both see every inbound envelope; use whichever fits your UI (or both at once).
Push — on_envelope#
Register a coroutine; it fires once per inbound envelope. Multiple callbacks compose in registration order. A callback that raises exceptions is logged and never propagates to the hub's dispatch path — a buggy UI cannot break the network.
Pull — next_envelope / envelopes#
Block until the next matching envelope arrives, or iterate the inbound stream:
Envelopes that don't match a next_envelope predicate are discarded — use on_envelope if you want to observe everything and await something specific.
Sending#
Outbound mirrors AgentClient. human.open(...) returns the same Channel handle, so multi-turn channel code is identical whether the initiator is a human or an agent.
| Call | Notes |
|---|---|
await human.open(type=, target=, ttl=, knobs=, intent=, labels=) | Open a channel as the initiator → Channel. target accepts peer names or agent ids. |
await human.send(channel_id, text, audience=, causation_id=) | Post an EV_TEXT envelope. |
await human.post_envelope(envelope) | Post an arbitrary envelope (stamps sender_id if blank). |
await human.close_channel(channel_id, reason=) | Close a channel this human is in. |
await human.disconnect() | Stop accepting deliveries; wakes any blocked next_envelope / envelopes consumers. Idempotent — call it in your shutdown path. |
Channel invites — auto_ack_invites#
When an agent opens a channel to a human, the hub waits for the human's EV_CHANNEL_INVITE_ACK before the channel reaches ACTIVE. With auto_ack_invites=True (the default) the HumanClient acks automatically the moment the invite arrives — the channel handshake completes with no UI round-trip, exactly like the default agent handler.
Pass auto_ack_invites=False if you want a human to decide whether to join (an "accept invite?" prompt). If so, your UI is responsible for emitting the ack:
Hooking up a UI#
The framework deliberately doesn't pick an input modality — you bridge the HumanClient to whatever UI you have. Two common shapes:
A web app / websocket bridge (push out, RPC in)#
Forward inbound envelopes to the client over a websocket; turn UI messages into sends.
A console / CLI loop (pull)#
input() is blocking — run it off the event loop with asyncio.to_thread so the network stays responsive while the user types.
Drain rate is yours to manage
The pull queue is unbounded by design — the embedder controls how fast it's drained via the UI. If it grows pathologically, the application has a UI bug to fix. Always call human.disconnect() on shutdown so blocked consumers wake up instead of hanging.
See also#
- Agent Clients and Handlers — the LLM-side counterpart.
- Hub & Identity —
Passport.kind,Rule, registration. - Human in the Loop — pausing a single
Agentmid-run for input (a different, in-process mechanism).