# 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, "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 newest message in the thread. Clients store it and pass it as `since`. **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 | |---|---|---| | `401` | token missing or not 64 hex | `{"success": false, "error": "missing or malformed X-Chat-Token"}` | | `413` | body > 32 KB, or text > 4000 chars | `{"success": false, "error": "text too long (max 4000 chars)"}` | | `429` | 20 messages per thread / 40 per IP per 10 min | `{"success": false, "error": "rate limit…"}` | | `507` | more than 500 threads on the host | `{"success": false, "error": "thread limit reached"}` | | `405` | wrong method | `{"success": false, "error": "Method not allowed"}` | 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. ## 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.