> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    A[🛑 Blocked turn] -->|server_request| B[🖥 Web/TUI client]
    B -->|server_reply| C[▶️ Turn continues]
    B -.->|reconnect: replayed| A

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef gateway fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff

    class A agent
    class B gateway
    class C ok
```

> [!NOTE]
>
> The gateway ships in the `praisonai-bot` package. `praisonai serve gateway` works exactly as documented here; for a standalone install see [praisonai-bot Migration](https://praison.ai/docs/guides/praisonai-bot-migration.md). No operator action is required to opt in beyond running the gateway.

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Agent as Agent turn
    participant Channel as RequestChannel
    participant Gateway
    participant Client as WS Client

    User->>Agent: "delete last week's logs"
    Agent->>Channel: request(GatewayServerRequest(kind="approval", prompt="…"))
    Channel->>Gateway: track {session_id: {request_id: req}}, Future waiter
    Gateway->>Client: {"type":"server_request","request":{…}}
    Note over Client: 💥 socket drops
    Client->>Gateway: (reconnect) hello {"since": N}
    Gateway->>Client: replay events + re-issue open requests
    Client->>Gateway: {"type":"server_reply","request_id":"…","value":"allow"}
    Gateway->>Gateway: validate session_id → scope → value → resolve Future
    Gateway->>Client: {"type":"server_reply_ack","request_id":"…","status":"resolved"}
    Agent->>User: (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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{"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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{"type": "server_reply", "request_id": "req-1", "value": "allow"}
```

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

```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{"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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{"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.

| `kind` | Meaning |
| - | - |
| `approval` | Allow or deny a blocked action |
| `choice` | Pick one of the offered `options` |
| `input` | Provide free-text input |
| `secret` | Provide a credential, rendered as masked input, isolated from model/logs/history |

| `kind` | Accepted `value` |
| - | - |
| `approval` | exactly `"allow"` or `"deny"` |
| `choice` | one of `options` (any string when `options` is absent) |
| `input` | any string |
| `secret` | any string (treated as a credential; see [Isolation Contract](#isolation-contract-secret-kind)) |

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

> [!NOTE]
>
> Every `server_reply` passes three checks, in order: **ownership → scope → value**.

### 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](https://praison.ai/docs/features/gateway-operator-scopes.md).

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

> [!WARNING]
>
> A `secret` value:
>
> * MUST NOT be logged.
> * MUST NOT be journalled (no inbound dedup store entry).
> * MUST NOT be echoed over the wire (the `server_reply_ack` carries no value).
> * MUST NOT be returned to the model or appended to message history by the caller.

> [!NOTE]
>
> Two mechanisms enforce this. `GatewayServerReply.value` is declared `field(repr=False)`, so any `repr()` or logger that prints the reply shows `GatewayServerReply(request_id='…')` only. The `server_reply` path resolves a `Future` for the in-process caller directly — it does not go through ingress, the inbound journal, or event history — and the ack carries no value.

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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Secret Isolation"
        A[👤 Human] -->|masked input| B[🖥 Client]
        B -->|server_reply| C[🛡 Gateway]
        C -->|Future.set_result| D[🔧 Tool/Callback]
        C -. never .-> E[🧠 LLM context]
        C -. never .-> F[📝 Journal / logs]
        C -. never .-> G[💬 Message history]
    end

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef gateway fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
    classDef blocked fill:#8B0000,stroke:#7C90A0,color:#fff

    class A,B input
    class C gateway
    class D ok
    class E,F,G blocked
```

***

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

> [tab] Python
>
> ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
> 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"))
> ```

> [tab] JavaScript
>
> ```javascript theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
> 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

> [accordion] 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.

> [accordion] 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`.

> [accordion] 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.

> [accordion] 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.

> [accordion] 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.

> [accordion] 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.

> [accordion] 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

> [card] [Gateway Session Continuity](https://praison.ai/docs/features/gateway-session-continuity.md)
>
> How event replay and this open-request replay compose on `hello` / `join`.

> [card] [Gateway Frame Codec](https://praison.ai/docs/features/gateway-frame-codec.md)
>
> The inbound frame codec that decodes `server_reply`.

> [card] [Approval Protocol](https://praison.ai/docs/features/approval-protocol.md)
>
> The elicitation surface the blocked turn calls into.

> [card] [Gateway Operator Scopes](https://praison.ai/docs/features/gateway-operator-scopes.md)
>
> Where `server_reply` sits in the scope table, and the approval escalation.

> [card] [Interactive Approval](https://praison.ai/docs/features/interactive-approval.md)
>
> The terminal-side sibling capability.

> [card] [Gateway Scoped Approvals](https://praison.ai/docs/features/gateway-scoped-approvals.md)
>
> Durable allow-always grants that compose with an approval-kind reply.

> [card] [Secret Egress Sentinels](https://praison.ai/docs/features/secret-egress-sentinels.md)
>
> The complementary outbound check: if a secret ever does leak into a response, sentinels detect it.

> [card] [Context Security Redaction](https://praison.ai/docs/features/context-security-redaction.md)
>
> Context-level redaction for sensitive strings that are already in history.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.