Gateway Server→Client Request Channel

Reconnect-safe interactive requests (approval / choice / input) from a blocked HITL turn to any connected gateway client

A blocked HITL turn asks any connected gateway client for an approval, a choice, free-text input, or a masked secret — and the request survives every reconnect until the client answers or the turn times out.

Diagram

server_request

server_reply

reconnect: replayed

🛑 Blocked turn

🖥 Web/TUI client

▶️ Turn continues

Quick Start

  1. Run an agent that blocks on approval

    An agent that already elicits approval gets this path automatically once a running gateway binds the request channel — no extra flags.

    python
    from praisonaiagents import Agent
    
    agent = Agent(name="assistant", instructions="Ask before deleting anything.", approval=True)
    # When this agent turn runs under a running gateway and blocks on approval,
    # the connected client sees an approval prompt automatically.
    agent.start("Clean up old logs.")
  2. Handle the frame from a client

    The client receives a server_request when a turn blocks and posts back a server_reply with the answer.

    python
    import json
    
    async def on_frame(raw, ws):
        msg = json.loads(raw)
        if msg["type"] == "server_request":
            req = msg["request"]
            # render req["prompt"] (and req["options"] for a "choice")
            await ws.send(json.dumps({
                "type": "server_reply",
                "request_id": req["request_id"],
                "value": "allow",
            }))
  3. Survive a reconnect

    Restart the client mid-prompt. On the next hello (or legacy join), the gateway re-issues every still-open request after replaying events, so the prompt re-renders on the new socket.

    python
    async def on_frame(raw, ws):
        msg = json.loads(raw)
        if msg["type"] == "server_request":
            render(msg["request"])          # idempotent — keyed on request_id
        elif msg["type"] == "server_reply_ack":
            clear(msg["request_id"])        # {"status": "resolved" | "unknown_request"}
  4. Timeout fallback

    A request defaults to a 120.0s timeout. On timeout the waiting turn receives None, the open request is cleared, and the elicitation code decides the fallback — typically deny/default.

    python
    reply = await channel.request(req, timeout_s=120.0)
    if reply is None:
        decision = "deny"   # timeout → treat as a decline path
  5. Ask for a secret mid-turn

    A tool asks the human for a credential with kind="secret"; the client masks the input. The one rule: never return reply.value from the tool and never put it in a string the model sees.

    python
    import uuid
    from praisonaiagents import Agent
    from praisonaiagents.gateway import GatewayServerRequest
    from praisonaiagents.session.context import get_request_channel
    
    async def clear_apt_cache() -> str:
        channel = get_request_channel()
        if channel is None:  # not running under a gateway (CLI / one-shot)
            return "No gateway client available to ask for a password."
        reply = await channel.request(
            GatewayServerRequest(
                request_id=str(uuid.uuid4()),
                kind="secret",
                prompt="sudo password?",
                session_id=SESSION_ID,  # the gateway session this turn runs in
            )
        )
        if reply is None:
            return "No password provided."
        ok = run_with_sudo("apt-get clean", password=reply.value)  # value stays here
        return "Cache cleared." if ok else "sudo failed."  # opaque result only
    
    agent = Agent(name="ops", instructions="You can run shell commands.", tools=[clear_apt_cache])
    agent.start("Clear the apt cache.")

How It Works

A turn blocks, the gateway tracks the open request, delivers a frame, and resolves the waiting future when the correlated reply arrives — even across a mid-prompt reconnect.

Diagram
WS ClientGatewayRequestChannelAgent turnUserWS ClientGatewayRequestChannelAgent turnUser💥 socket drops“delete last week’s logs”request(GatewayServerRequest(kind=”approval”, prompt=”…”))track {session_id: {request_id: req}}, Future waiter{“type”:”server_request”,”request”:{…}}(reconnect) hello {“since”: N}replay events + re-issue open requests{“type”:”server_reply”,”request_id”:”…”,”value”:”allow”}validate session_id → scope → value → resolve Future{“type”:”server_reply_ack”,”request_id”:”…”,”status”:”resolved”}(real reply)

The core reaches the channel through a task-local slot: get_request_channel() returns the bound implementation (or None in CLI/one-shot, falling back to the existing elicitation path). The gateway registers the channel with register_request_channel(...) at the start of a turn and restores the previous slot with clear_request_channel(token) — the same pattern as the outbound messenger and conversation requester.


Wire Frames

Copy these frames verbatim — integrators code straight against them.

Server → client — delivered when a turn blocks, and re-issued on reconnect:

json
{"type": "server_request",
 "request": {
   "request_id": "req-1",
   "kind": "approval",
   "prompt": "Deploy to prod?",
   "options": ["prod-eu", "prod-us"],
   "session_id": "sess-…"
 }}

kind is one of "approval", "choice", "input", "secret". options is present only when kind == "choice". A client receiving kind == "secret" MUST render a masked input (e.g. <input type="password"> on the web, getpass-style on a TUI).

Client → server — the correlated answer:

json
{"type": "server_reply", "request_id": "req-1", "value": "allow"}

Server → client — correlation ack (always sent; never leaks another session’s request kind):

json
{"type": "server_reply_ack", "request_id": "req-1", "status": "resolved"}
{"type": "server_reply_ack", "request_id": "req-1", "status": "unknown_request"}

For a secret reply the ack is identical in shape. The value is not echoed back; it is handed only to the waiting caller’s Future in-process.

An unknown_request ack is a safe no-op: an unknown or already-answered id acks without leaking state.

Server → client — structured error frames, each carrying a code:

json
{"type": "error", "code": "session_mismatch",   "message": "server_reply for a request not owned by this session"}
{"type": "error", "code": "insufficient_scope", "message": "insufficient scope", "required_scope": "approvals"}
{"type": "error", "code": "invalid_value",      "message": "server_reply value not permitted for this request"}

Request Kinds and Value Contract

Four request kinds map to four value contracts, validated at the handler.

kindMeaning
approvalAllow or deny a blocked action
choicePick one of the offered options
inputProvide free-text input
secretProvide a credential, rendered as masked input, isolated from model/logs/history
kindAccepted value
approvalexactly "allow" or "deny"
choiceone of options (any string when options is absent)
inputany string
secretany string (treated as a credential; see Isolation Contract)

An unrelated value is rejected with {"code": "invalid_value"} and the request stays open.


Reconnect Replay

Still-open requests are re-issued whenever a client resumes, which is the whole reason this channel exists.

Replay runs at the end of both the hello (protocol v2+ resume with since) and legacy join handlers, right after event replay. Only entries still present in the session’s open-request set are re-sent, so an already-answered or timed-out request is never re-issued. The join path also rebinds the session’s preferred delivery target (session._client_id = client_id) so the joining client actually receives the next server_request — without this rebind, a new request after join would be routed to the stale client. This puts join on parity with hello for this feature.


Security Model

Session ownership

Only a client bound to the request’s own session_id may answer it. A WRITE client in another session that guesses a request_id is rejected with {"code": "session_mismatch"} — the victim turn stays open.

Scopes

server_reply classifies as write in the central registry. The handler escalates to approvals when the request’s kind == "approval" (parity with approvals.resolve). Choice, input, and secret still require write. A WRITE-only client can answer choice/input/secret but gets {"code": "insufficient_scope", "required_scope": "approvals"} for an approval. See Gateway Operator Scopes.

Value contract

The reply value is enforced per the table above; an invalid value is rejected with {"code": "invalid_value"} and the request stays open.

Isolation Contract (secret kind)

A secret reply’s value reaches only the specific caller that awaited request(...); it never enters the transcript, the inbound journal, the model context, or any log.

The gateway cannot stop a tool from returning the secret to the model itself. The caller (tool or callback) uses reply.value only inside its own scope and returns a non-sensitive result to the agent.

Diagram

Secret Isolation

masked input

server_reply

Future.set_result

never

never

never

👤 Human

🖥 Client

🛡 Gateway

🔧 Tool/Callback

🧠 LLM context

📝 Journal / logs

💬 Message history


Timeout

request() defaults to a timeout_s of 120.0. On timeout it returns None and clears the open-request entry, so a later reconnect does not re-issue a timed-out prompt. The blocked turn’s elicitation code interprets None as its fallback — typically deny/default.


Backward Compatibility

Unbound (CLI / one-shot) behaviour is unchanged — get_request_channel() returns None and existing elicitation paths run as before. The praisonai-bot import of the two core shapes is guarded, so it keeps working against an older core. There is no new user-authored surface, no new Agent parameter, and no new dependency.


Building a Client

The minimum a client needs: receive a server_request, render the prompt, post back a server_reply, and clear on the ack — keyed idempotently on request_id.

For secret, the masking is the client’s job — the server has already marked the value as isolated, but only the client controls what the human’s screen shows.

Python

python
import asyncio, getpass, json
import websockets

async def client(url):
    async with websockets.connect(url) as ws:
        async for raw in ws:
            msg = json.loads(raw)
            if msg["type"] == "server_request":
                req = msg["request"]
                # render idempotently, keyed on req["request_id"]
                if req["kind"] == "approval":
                    value = "allow"            # or "deny"
                elif req["kind"] == "choice":
                    value = req["options"][0]  # one of the offered options
                elif req["kind"] == "secret":
                    value = getpass.getpass(req["prompt"])  # never echoes to the terminal
                else:
                    value = "some text"        # input
                await ws.send(json.dumps({
                    "type": "server_reply",
                    "request_id": req["request_id"],
                    "value": value,
                }))
            elif msg["type"] == "server_reply_ack":
                # {"status": "resolved"} or {"status": "unknown_request"}
                pass

asyncio.run(client("ws://localhost:8765/ws"))

JavaScript

javascript
const ws = new WebSocket("ws://localhost:8765/ws");

ws.onmessage = async (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === "server_request") {
    const req = msg.request;
    // render idempotently, keyed on req.request_id
    let value;
    if (req.kind === "approval") value = "allow";        // or "deny"
    else if (req.kind === "choice") value = req.options[0];
    else if (req.kind === "secret") {
      // render <input type="password"> for req.prompt
      value = await promptMasked(req.prompt);
    }
    else value = "some text";                            // input
    ws.send(JSON.stringify({
      type: "server_reply",
      request_id: req.request_id,
      value,
    }));
  } else if (msg.type === "server_reply_ack") {
    // msg.status is "resolved" or "unknown_request"
  }
};

Best Practices

Never return a secret value to the model

A tool that wraps request(..., kind="secret") uses the credential inside its own scope and returns an opaque success/failure string to the agent — never the credential itself, and never a string that embeds it.

Prefer secret over input for any credential-shaped prompt

If the thing being asked for is a password, API key, OTP, token, or any other value you would not want in a log, use kind="secret". An input reply is journalled, kept in history, and sent to the LLM; the isolation contract applies only to secret.

Set a sensible timeout_s

The default 120s is safe for a human-in-the-loop web UI. Interactive shells that expect a fast answer may want a shorter timeout so a blocked turn does not sit idle.

Re-render on server_request at any time

A reconnect can re-issue a prompt the user has already seen. Render idempotently, keyed on request_id, so a replayed request updates the existing prompt instead of duplicating it.

Grant APPROVALS to any role that answers approvals

A WRITE-only client can answer choice and input but gets insufficient_scope for approval prompts. Size tokens so any role that resolves approvals over the transport holds the approvals scope.

Never guess a request_id

The ownership check rejects cross-session replies with session_mismatch. Treat an unknown_request ack as a signal that client state has drifted, not as a retry cue.

Interpret None from request() as a decline path

The timeout returns None and clears the request. Do not retry the same request_id after that — start a fresh turn if the action is still needed.


Related