# 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

|     |     |
| --- | --- |
| ```<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>``` | ```<br>from autogen.beta.network import HubClient, LocalLink, Passport<br>hc = HubClient(LocalLink(hub), hub=hub)<br>human = await hc.register_human(<br>    Passport(name="operator"),<br>    resume=None,            # optional — Resume() defaults<br>    rule=None,              # optional — Rule(...) for governance, same as agents<br>    auto_ack_invites=True,  # auto-accept channel invites (see below)<br>)<br>``` |

`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 `HumanClient`s.

## 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.

|     |     |
| --- | --- |
| ```<br>1<br>2<br>3<br>4<br>5<br>``` | ```<br>async def on_inbound(envelope) -> None:<br>    await ui.push_event(envelope)   # forward to a websocket, queue, etc.<br>human.on_envelope(on_inbound)<br>human.remove_envelope_callback(on_inbound)   # detach later<br>``` |

### Pull — `next_envelope` / `envelopes`

Block until the next matching envelope arrives, or iterate the inbound stream:

|     |     |
| --- | --- |
| ```<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>11<br>12<br>13<br>14<br>15<br>16<br>17<br>18<br>``` | ```<br>from autogen.beta.network import EV_TEXT<br># Wait for the next text reply from a specific peer.<br>reply = await human.next_envelope(<br>    predicate=lambda e: e.event_type == EV_TEXT and e.sender_id == peer_id,<br>    timeout=60.0,   # raises asyncio.TimeoutError if exceeded<br>)<br># Or stream everything until disconnect.<br>async for envelope in human.envelopes():<br>    ...<br># Channel-scoped wait (symmetric with AgentClient.wait_for_channel_event):<br>env = await human.wait_for_channel_event(<br>    channel_id=channel.channel_id,<br>    predicate=lambda e: e.event_type == EV_TEXT,<br>    timeout=300.0,<br>)<br>``` |

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.

|     |     |
| --- | --- |
| ```<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>11<br>``` | ```<br>from autogen.beta.network import CONVERSATION_TYPE<br>channel = await human.open(type=CONVERSATION_TYPE, target=expert.agent_id)<br>await channel.send("Hi — what's a good first ML concept to learn?")<br>await channel.close(reason="done")<br># Convenience for an existing channel id:<br>await human.send(channel_id, "another message")<br># Escape hatch for adapter-shaped envelopes (e.g. a workflow EV_PACKET seed):<br>await human.post_envelope(envelope)<br>``` |

| 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:

|     |     |
| --- | --- |
| ```<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>11<br>12<br>13<br>14<br>15<br>16<br>17<br>18<br>``` | ```<br>from autogen.beta.network import EV_CHANNEL_INVITE, EV_CHANNEL_INVITE_ACK, Envelope<br>human = await hc.register_human(Passport(name="operator"), auto_ack_invites=False)<br>async def gate_invites(envelope) -> None:<br>    if envelope.event_type != EV_CHANNEL_INVITE:<br>        return<br>    if await ui.confirm(f"Join channel {envelope.channel_id}?"):<br>        await human.post_envelope(Envelope(<br>            channel_id=envelope.channel_id,<br>            sender_id=human.agent_id,<br>            event_type=EV_CHANNEL_INVITE_ACK,<br>            event_data={"channel_id": envelope.channel_id},<br>            causation_id=envelope.envelope_id,<br>        ))<br>    # otherwise let the hub's invite-ack timeout close the channel<br>human.on_envelope(gate_invites)<br>``` |

## 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.

|     |     |
| --- | --- |
| ```<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>11<br>12<br>13<br>14<br>15<br>16<br>17<br>18<br>19<br>20<br>21<br>22<br>23<br>24<br>``` | ```<br>async def serve(websocket, human):<br>    # outbound: hub -> browser<br>    async def to_browser(envelope) -> None:<br>        await websocket.send_json({<br>            "channel": envelope.channel_id,<br>            "from": envelope.sender_id,<br>            "type": envelope.event_type,<br>            "data": envelope.event_data,<br>        })<br>    human.on_envelope(to_browser)<br>    # inbound: browser -> hub<br>    try:<br>        async for msg in websocket:<br>            payload = msg.json()<br>            if payload["action"] == "send":<br>                await human.send(payload["channel"], payload["text"])<br>            elif payload["action"] == "open":<br>                await human.open(type=payload["type"], target=payload["target"])<br>            elif payload["action"] == "close":<br>                await human.close_channel(payload["channel"], reason="user_closed")<br>    finally:<br>        human.remove_envelope_callback(to_browser)<br>        await human.disconnect()<br>``` |

### 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.

|     |     |
| --- | --- |
| ```<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>11<br>12<br>13<br>14<br>15<br>16<br>17<br>18<br>19<br>20<br>21<br>``` | ```<br>import asyncio<br>from autogen.beta.network import CONVERSATION_TYPE, EV_TEXT<br>channel = await human.open(type=CONVERSATION_TYPE, target=expert.agent_id)<br>while True:<br>    text = (await asyncio.to_thread(input, "you> ")).strip()<br>    if not text or text.lower() in {"quit", "exit"}:<br>        break<br>    await channel.send(text)<br>    try:<br>        reply = await human.next_envelope(<br>            predicate=lambda e: e.event_type == EV_TEXT and e.sender_id == expert.agent_id,<br>            timeout=60.0,<br>        )<br>        print(f"expert> {reply.event_data['text']}")<br>    except asyncio.TimeoutError:<br>        print("expert> (no reply within 60s)")<br>await channel.close(reason="operator_done")<br>await human.disconnect()<br>``` |

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.
