1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
|
# 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/<thread>.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=<epoch>`
`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_<thread>` 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 <msg>` and `hermes <msg>` 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.
|