# newOS4 ⇄ Hermes chat contract **Status:** frozen 2026-10-23. Any change here changes both sides. **Scope:** one conversation per browser token. No accounts, no cookies, no admin session. ## Identity | Item | Value | |---|---| | Token | 64 lowercase hex, generated by the browser (`crypto.getRandomValues`, 32 bytes) | | Header | `X-Chat-Token: <64 hex>` | | Thread | `substr(sha256(token), 0, 16)` — computed server-side, **never** sent by the client | | Storage | `spaces/newos4/data/chat/.json`, one file per thread, atomic replace (tmp + rename) | | Reachability | `data/chat/` is **not** web-reachable. No listing endpoint exists. | ## POST `/spaces/newos4/api/chat/post.php` Request: ```text X-Chat-Token: <64 hex> Content-Type: application/json {"text": "message body", "kind": "operator"|"agent"} ``` `kind` defaults to `operator`. Response `201`: ```json {"success": true, "id": "MSG-20261023-184000-a1b2c3", "epoch": 1792778400123, "ts": "2026-10-23T18:40:00+00:00", "thread": "a1b2c3d4e5f60718"} ``` ## GET `/spaces/newos4/api/chat/poll.php?since=` `since` is **epoch milliseconds**, an integer. Omitted or `0` returns the full thread (up to 50 newest). Seconds were not enough: two messages in the same second made a strict `>` drop the second one. Response `200`: ```json {"success": true, "thread": "a1b2c3d4e5f60718", "messages": [{"id": "MSG-…", "epoch": 1792778400123, "ts": "2026-10-23T18:40:00+00:00", "author": "operator", "text": "…"}], "awaiting": true, "truncated": false, "cursor": 1792778400123} ``` - `awaiting` = the last message in the thread was **not** written by `hermes`. There is no unread counter: the server keeps no read state, so a counter could never clear. - `cursor` = epoch of the **last message actually returned**. With more than 50 new messages the response is cut to 50 (`truncated: true`) and the cursor stops at the cut — returning the thread's real tail would skip everything in between, permanently. **The cursor is epoch, never `ts`.** `ts` carries an offset (`+00:00`), and comparing strings with mixed offsets silently drops messages in the same second and reorders others. ## Authors | `author` | Written by | |---|---| | `operator` | the human in the app (`chat` / `feedback` command) | | `agent` | the in-page AI (`chat_hermes` tool) | | `hermes` | Hermes, host-side (`scripts/chat.py reply`) | ## Errors — no silent success | Code | When | Body | |---|---|---| | Status | `code` | Retry? | |---|---|---| | `401` | `bad_token` | no — `hermes-reset` | | `413` | `too_long` (4000 codepoints) / `too_large` (32 KB body) | no — shorten | | `429` | `rate_limited` + `retry_after_ms`, `scope`, `limit`, `window_s`, and a `Retry-After` header | yes, after the wait | | `507` | `thread_limit` | no | | `405` | `method` | no | | `500` | `thread file is corrupt` | no — the file is left untouched; a corrupt thread is never silently reset | | `503` | *(nginx, HTML body)* | **yes** — the front-end rate zone (`60 r/min`, burst 50) fired before the app. Back off; do not parse it as JSON. | Every body is `{"success": false, "code": "…", "error": "…", …}`. A client may retry on `429` and `503` only — everything else is permanent for that payload. Rules that follow from this table: - **Auth is checked before anything touches disk.** A `401` means no file was opened. - **Text is rejected, never truncated.** A shortened question gets a wrongly scoped answer. - Rate limits are two windows, per thread **and** per IP: a token is mintable in a loop, an IP is not. - **Two readers, two cursors.** The terminal's 30 s poller and the agent's `chat_hermes read` read the same thread but store separate cursors client-side (`webx11_chat_state.cursor` for display, `webx11_chat_agent_cursor_` for the tool). Sharing one made the poller consume the reply before the model could see it. - **`epoch` is strictly increasing.** Two posts in the same millisecond get `max(now, last+1)`, so a `since` reader never loses the second one. ## Security posture The token is minted by the client, so this channel is **public write by design** — there is no login. Anyone who can reach the endpoint can post. Consequences, deliberately accepted: - The auto-reply agent treats message text as **untrusted data**: it may read files to check a claim, but it never runs a command or writes anything because a message asked it to. - Bounded by 20/thread, 40/IP per 10 min, 500 threads, 200 messages per thread, 30 days. - `data/chat/` is not reachable over HTTP (`403`); thread ids are `sha256(token)[0:16]`, so there is nothing to enumerate and no id in any URL. Closing this properly needs platform-session auth, which the browser app does not have. Do not bolt on half-auth: it moves the hole without closing it. ## Retention Newest 200 messages per thread, 30 days. Older messages are dropped on append and swept by the relay cron. A conversation is not an archive. ## What this replaces `feedback ` and `hermes ` are the **same code path**; `feedback` is the legacy alias, `hermes` the explicit one. Neither writes to the bug-report intake any more. The command is **not** called `chat`: `ChatStudioModule` already registers `chat` (it toggles the chat overlay) and command dispatch walks modules in order, so a second `chat` is unreachable. `hermes-poll` = `feedback-inbox`; `hermes-reset` rotates the token. `POST /api/debug/agent-replies.php` is retired in favour of this contract: after the acceptance round trip it answers `410 Gone` with a pointer to `/api/chat/post.php`. One write path, one truth.