Runtime footer: app-controlled model/context/cwd/latency/cost under replies
Hermes can append a text "runtime footer" (model, context %, workdir, latency, cost) to final replies, but only when display.runtime_footer is enabled in the hermes config. We want the same info but controlled by the APP, not the gateway config. So the gateway now ALWAYS sends the data as a structured `runtime` object on final assistant messages, and the app decides whether/what to show. Gateway (gateway-plugin/): - protocol.py: new runtime_footer() helper + `runtime` field on the message / message.stop frames. Keys (all optional, absent when the data is unavailable — e.g. no cost for local models): model (vendor prefix dropped), context_pct (0-100), cwd (home-relative), latency (seconds), cost (USD). - adapter.py: a post_api_request plugin hook captures the turn's model + prompt tokens + start time (platform-filtered to android so other platforms don't pollute the buffer). _build_runtime_footer() resolves the model's context window (cached, best-effort, off the event loop via asyncio.to_thread with a timeout) and computes context_pct. The runtime object is attached on every final send (streaming message.stop and non-streaming message, plus the fallback paths). - outbox.py: `runtime` preserved in history reconstruction so the footer survives a restart / first open. App (app/shared/): - Protocol.kt: RuntimeMeta data class + `runtime` on MessagePayload / MessageStopPayload / HistoryMessage. - ChatStore.kt: `runtime` on MessageItem, wired through live + history reconciliation. - SecureStore.kt (+ Android/Desktop actuals): runtimeFooterEnabled + runtimeFooterFields (persisted per device). - IrisController.kt: StateFlows + toggleRuntimeFooter() / toggleRuntimeField(); RUNTIME_FIELD_KEYS / default set / parser. - SettingsScreen.kt: "Runtime footer" switch; when on, an expandable chip menu (Model · Context % · Workdir · Latency · Cost) to pick fields. - ChatScreen.kt: footer rendered on the SAME line as the timestamp (footer left, time right, Telegram-style), only for final non-streaming assistant answers; Inspector pane now shows the runtime fields too. Docs: 04-wire-protocol.md + frames.schema.json document the `runtime` object. Verified end-to-end on device: final replies carry `qwen3.8-27B-exl3-4.5bpw · 53% · ~ · 38s` with the time right-aligned on the same line; 69/69 gateway tests pass, Kotlin builds + tests pass.
This commit is contained in:
1 parent
678c0344c8
commit
9286937e2d
13 files changed
+979
-290
No files matched your search
+102
-3
@@ -34,7 +34,9 @@ JSON `media.upload.end` / final ack. See `07-media.md`.
|
||||
## Server → App (events / responses)
|
||||
|
||||
### `hello.ack`
|
||||
|
||||
Pairing succeeded.
|
||||
|
||||
```json
|
||||
{"type":"hello.ack","payload":{
|
||||
"server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true,
|
||||
@@ -44,13 +46,16 @@ Pairing succeeded.
|
||||
"channels":[{"chat_id":"android:default","name":"Default","kind":"default","is_default":true}]
|
||||
}}
|
||||
```
|
||||
|
||||
`last_pushed_cursor` is the highest outbox cursor already delivered to THIS
|
||||
device via the push backend (0 = never). The app skips system notifications
|
||||
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
|
||||
woke the device via push (dedupe, `08-push.md` §8.7).
|
||||
|
||||
### `message`
|
||||
|
||||
A final / standalone message.
|
||||
|
||||
```json
|
||||
{"type":"message","chat_id":"android:default","thread_id":null,
|
||||
"payload":{
|
||||
@@ -60,40 +65,65 @@ A final / standalone message.
|
||||
"media":[{"media_id":"md_5","kind":"video","mime":"video/mp4","size":123456,
|
||||
"filename":"clip.mp4"}], // optional
|
||||
"reply_to":"m_8999", // optional
|
||||
"model":"qwen3-27b","tokens":11,"ts":1724000000000
|
||||
"model":"qwen3-27b","tokens":11,"ts":1724000000000,
|
||||
"runtime":{"model":"qwen3-27b","context_pct":38,"cwd":"~",
|
||||
"latency":22.5,"cost":0.0012} // optional; see below
|
||||
}}
|
||||
```
|
||||
|
||||
`role` ∈ `user | assistant | system | cron`. `reasoning` present only when the
|
||||
agent produced reasoning and `show_reasoning` is on.
|
||||
|
||||
`runtime` (optional) is the **structured runtime-metadata footer** the app
|
||||
renders under final assistant messages (Telegram-style). The gateway ALWAYS
|
||||
sends it on final assistant messages; whether/what is shown is a **per-app
|
||||
setting** (Settings → Runtime footer), NOT a hermes config. Keys (all
|
||||
optional; absent when the data is unavailable, e.g. local models have no
|
||||
cost):
|
||||
|
||||
- `model` — bare model id, vendor prefix dropped (`gpt-5.4`)
|
||||
- `context_pct` — last-call context occupancy, 0-100 (int)
|
||||
- `cwd` — home-relative working dir (`~`)
|
||||
- `latency` — wall-clock turn duration, seconds (float)
|
||||
- `cost` — turn cost, USD (float)
|
||||
|
||||
### `message.start` / `message.update` / `message.stop`
|
||||
|
||||
Streaming a bubble. `update` carries the **full** current text (app replaces).
|
||||
|
||||
```json
|
||||
{"type":"message.start","chat_id":"…","payload":{"message_id":"m_9002","role":"assistant"}}
|
||||
{"type":"message.update","chat_id":"…","payload":{"message_id":"m_9002","text":"partial…"}}
|
||||
{"type":"message.stop","chat_id":"…","payload":{"message_id":"m_9002","final_text":"full…",
|
||||
"reasoning":"…","model":"…","tokens":11}}
|
||||
"reasoning":"…","model":"…","tokens":11,
|
||||
"runtime":{"model":"…","context_pct":38,"cwd":"~","latency":22.5}}}
|
||||
```
|
||||
|
||||
### `message.deleted`
|
||||
|
||||
The given message(s) were deleted from a chat/thread. Response to a
|
||||
`message.delete` request (`id` set) **and** broadcast to every device so all of
|
||||
them drop the message(s) from their cache. Also outboxed, so a device that was
|
||||
offline learns of the deletion on its next `sync`.
|
||||
|
||||
```json
|
||||
{"type":"message.deleted","id":30,"chat_id":"android:default","thread_id":null,
|
||||
"payload":{"message_ids":["m_9001","m_9002"]}}
|
||||
```
|
||||
|
||||
### `commentary`
|
||||
|
||||
Intermediate assistant beat (between tool iterations).
|
||||
|
||||
```json
|
||||
{"type":"commentary","chat_id":"…","payload":{"message_id":"m_9003","text":"Let me inspect the repo first."}}
|
||||
```
|
||||
|
||||
### `tool.start` / `tool.progress` / `tool.end`
|
||||
|
||||
**Structured** tool events. The app decides how much to show (everything /
|
||||
truncated / nothing).
|
||||
|
||||
```json
|
||||
{"type":"tool.start","chat_id":"…","payload":{
|
||||
"index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"}}}
|
||||
@@ -101,24 +131,31 @@ truncated / nothing).
|
||||
{"type":"tool.end","chat_id":"…","payload":{"index":3,"name":"terminal","ok":true,"duration":12.4,
|
||||
"output_preview":"12 passed"}}
|
||||
```
|
||||
|
||||
`args` may be large; the app truncates per its setting. `output_preview` is a
|
||||
short tail (full output is not streamed — it lives in agent history).
|
||||
|
||||
### `typing` / `typing.stop`
|
||||
|
||||
```json
|
||||
{"type":"typing","chat_id":"…","payload":{"on":true}}
|
||||
```
|
||||
|
||||
### `notification`
|
||||
|
||||
In-app banner (foreground) and/or push mirror (background).
|
||||
|
||||
```json
|
||||
{"type":"notification","chat_id":"…","payload":{
|
||||
"kind":"channel_renamed","title":"ARIA","body":"Renamed topic to …","ts":1724000000000}}
|
||||
```
|
||||
|
||||
`kind` ∈ `channel_renamed | channel_created | cron | approval | clarify | generic`.
|
||||
|
||||
### `picker.model` / `picker.choice` / `picker.clarify` / `picker.approval` / `picker.confirm`
|
||||
|
||||
Interactive prompts. App renders a native picker; answers via `picker.select`.
|
||||
|
||||
```json
|
||||
{"type":"picker.model","chat_id":"…","payload":{
|
||||
"picker_id":"pm_1","current_model":"qwen3-27b","current_provider":"local",
|
||||
@@ -129,19 +166,24 @@ Interactive prompts. App renders a native picker; answers via `picker.select`.
|
||||
```
|
||||
|
||||
### `channel.list` / `channel.created` / `channel.renamed` / `channel.deleted`
|
||||
|
||||
Channel directory updates. **Broadcast to all connected devices** (no explicit
|
||||
subscribe; the server pushes to every open WS).
|
||||
|
||||
```json
|
||||
{"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports",
|
||||
"kind":"channel","parent_chat_id":null}}
|
||||
```
|
||||
|
||||
`channel.created` may carry `"auto":true` for a thread the gateway minted
|
||||
itself for an incoming message (auto-threading): the name is an instant
|
||||
derived title, and a follow-up `channel.renamed` upgrades it to the model's
|
||||
title.
|
||||
|
||||
### `history`
|
||||
|
||||
Response to a `history` request. Returns a page of messages for a chat/thread.
|
||||
|
||||
```json
|
||||
{"type":"history","id":20,"chat_id":"android:default","thread_id":null,
|
||||
"payload":{
|
||||
@@ -154,17 +196,20 @@ Response to a `history` request. Returns a page of messages for a chat/thread.
|
||||
"oldest_message_id":"m_8990"
|
||||
}}
|
||||
```
|
||||
|
||||
`messages` are ordered oldest → newest. Paginate with `before_message_id` in the
|
||||
request. The app uses this to **populate the initial view** when a channel is
|
||||
opened (complements `sync`, which only replays undelivered outbox frames).
|
||||
|
||||
### `commands.catalog`
|
||||
|
||||
Request (app → server, empty payload) and response: the gateway's
|
||||
slash-command catalog for the app's `/` drawer. Derived from hermes' central
|
||||
`COMMAND_REGISTRY` (the same source the gateway help and the Telegram command
|
||||
menu use), restricted to commands available on gateway surfaces, plus
|
||||
plugin-registered commands. The app fuzzy-matches the typed prefix
|
||||
client-side (no `commands.complete` round-trip).
|
||||
|
||||
```json
|
||||
{"type":"commands.catalog","id":21,"payload":{
|
||||
"commands":[
|
||||
@@ -173,11 +218,14 @@ client-side (no `commands.complete` round-trip).
|
||||
{"name":"/status","description":"Show session status","args_hint":"","category":"Info","aliases":[]}
|
||||
]}}
|
||||
```
|
||||
|
||||
`name`/`aliases` carry the leading slash; `args_hint` is the registry's
|
||||
argument placeholder (empty when the command takes none).
|
||||
|
||||
### `commands.complete`
|
||||
|
||||
Response to a `commands.complete` request. Autocomplete matches for a typed prefix.
|
||||
|
||||
```json
|
||||
{"type":"commands.complete","id":22,"payload":{
|
||||
"prefix":"/mod",
|
||||
@@ -187,15 +235,19 @@ Response to a `commands.complete` request. Autocomplete matches for a typed pref
|
||||
```
|
||||
|
||||
### `agent.busy` / `agent.idle`
|
||||
|
||||
Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`.
|
||||
|
||||
```json
|
||||
{"type":"agent.busy","chat_id":"android:default","thread_id":null,
|
||||
"payload":{"reason":"processing"}}
|
||||
{"type":"agent.idle","chat_id":"android:default","thread_id":null,"payload":{}}
|
||||
```
|
||||
|
||||
`reason` ∈ `processing | tool | waiting_input | cron`.
|
||||
|
||||
### `search.results`
|
||||
|
||||
```json
|
||||
{"type":"search.results","id":7,"payload":{
|
||||
"query":"deploy","scope":"all","hits":[
|
||||
@@ -204,43 +256,56 @@ Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`
|
||||
```
|
||||
|
||||
### `media.offer`
|
||||
|
||||
Agent-sent media is available; app pulls bytes.
|
||||
|
||||
```json
|
||||
{"type":"media.offer","chat_id":"…","payload":{
|
||||
"media_id":"md_5","kind":"video","mime":"video/mp4","size":123456,"filename":"clip.mp4"}}
|
||||
```
|
||||
|
||||
### `read.receipt`
|
||||
|
||||
The gateway acknowledges that the agent has received and started processing
|
||||
the user's message. The app uses it to show ✓✓ on user bubbles.
|
||||
|
||||
```json
|
||||
{"type":"read.receipt","chat_id":"android:default","payload":{"message_id":"m_9001"}}
|
||||
```
|
||||
|
||||
Emitted to the originating connection when a `message.send` is accepted for
|
||||
processing (at the moment it is handed to the agent), for user-originated
|
||||
messages only.
|
||||
|
||||
### `status`
|
||||
|
||||
Gateway health state. Broadcast to all connected clients at startup
|
||||
(`state: "online"`); `restarting` / `degraded` are reserved for future use.
|
||||
|
||||
```json
|
||||
{"type":"status","payload":{"state":"online"}}
|
||||
```
|
||||
|
||||
`state` ∈ `online | restarting | degraded`.
|
||||
|
||||
### `error`
|
||||
|
||||
```json
|
||||
{"type":"error","id":7,"payload":{"code":"not_found","message":"chat_id unknown"}}
|
||||
```
|
||||
|
||||
`code` ∈ `auth | not_found | rate_limited | media_too_large | unsupported | internal`.
|
||||
|
||||
### `pong`
|
||||
|
||||
Keepalive reply to `ping`.
|
||||
|
||||
## App → Server (requests / actions)
|
||||
|
||||
### `hello`
|
||||
|
||||
First frame; auth + caps.
|
||||
|
||||
```json
|
||||
{"type":"hello","payload":{
|
||||
"token":"<ANDROID_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S",
|
||||
@@ -249,12 +314,15 @@ First frame; auth + caps.
|
||||
```
|
||||
|
||||
### `message.send`
|
||||
|
||||
Send text (or a `/slash-command`).
|
||||
|
||||
```json
|
||||
{"type":"message.send","id":10,"chat_id":"android:default","thread_id":null,
|
||||
"payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"],
|
||||
"auto_thread":false}}
|
||||
```
|
||||
|
||||
`media_refs` reference completed `media.upload`s to attach.
|
||||
`auto_thread` (optional, default false) asks the gateway to mint a fresh
|
||||
thread for the message (auto-threading, `06-channels-cron-search.md` §6.3):
|
||||
@@ -264,7 +332,9 @@ that is not a slash command. The gateway then broadcasts
|
||||
`thread_id`.
|
||||
|
||||
### `media.upload.start` / (binary) / `media.upload.end`
|
||||
|
||||
See `07-media.md`.
|
||||
|
||||
```json
|
||||
{"type":"media.upload.start","id":11,"payload":{
|
||||
"media_ref":"mu_1","kind":"image","mime":"image/jpeg","size":204800,"filename":"a.jpg"}}
|
||||
@@ -273,26 +343,33 @@ See `07-media.md`.
|
||||
```
|
||||
|
||||
### `media.upload.ack`
|
||||
|
||||
Server → App response to `media.upload.end`: the ref is cached and may now be
|
||||
referenced in a `message.send` `media_refs`. Failures use `error` frames instead.
|
||||
|
||||
```json
|
||||
{"type":"media.upload.ack","id":11,"payload":{"ok":true,"media_ref":"mu_1"}}
|
||||
```
|
||||
|
||||
### `media.pull`
|
||||
|
||||
Request agent-sent media bytes.
|
||||
|
||||
```json
|
||||
{"type":"media.pull","id":12,"payload":{"media_id":"md_5"}}
|
||||
// server replies: binary frames, then {"type":"media.pull.end","id":12,"payload":{"ok":true}}
|
||||
```
|
||||
|
||||
### `picker.select`
|
||||
|
||||
Answer an interactive picker.
|
||||
|
||||
```json
|
||||
{"type":"picker.select","id":13,"payload":{"picker_id":"pm_1","value":"local/qwen3-27b"}}
|
||||
```
|
||||
|
||||
### `channel.create` / `channel.rename` / `channel.set_default` / `channel.delete`
|
||||
|
||||
```json
|
||||
{"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}}
|
||||
{"type":"channel.rename","id":15,"chat_id":"android:chan_7","payload":{"name":"Reports"}}
|
||||
@@ -300,79 +377,101 @@ Answer an interactive picker.
|
||||
```
|
||||
|
||||
### `search`
|
||||
|
||||
```json
|
||||
{"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}}
|
||||
{"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"android:chan_7","thread_id":null}}
|
||||
```
|
||||
|
||||
`scope` ∈ `all | chat`.
|
||||
|
||||
### `read.receipt`
|
||||
|
||||
App → server: "user has viewed this message." Server stores the read state and
|
||||
broadcasts to other devices (for multi-device ✓✓ sync). The app uses it to
|
||||
mark messages as read locally (✓✓ on user bubbles).
|
||||
|
||||
```json
|
||||
{"type":"read.receipt","payload":{"chat_id":"android:default","message_id":"m_9001"}}
|
||||
```
|
||||
|
||||
### `history`
|
||||
|
||||
Load a page of messages for a chat/thread (initial open, scroll-up pagination).
|
||||
|
||||
```json
|
||||
{"type":"history","id":20,"chat_id":"android:default","thread_id":null,
|
||||
"payload":{"before_message_id":"m_8990","limit":50}}
|
||||
```
|
||||
|
||||
`before_message_id` — return messages older than this (omit for newest page).
|
||||
`limit` — max messages (default 50, max 200).
|
||||
|
||||
### `message.delete`
|
||||
|
||||
Delete the given message(s) from a chat/thread. The server removes them from the
|
||||
outbox (so `history`/`sync` no longer return them) and broadcasts
|
||||
`message.deleted` to every device. Idempotent: a message already gone (pruned by
|
||||
retention) still yields a `message.deleted` broadcast so live caches drop it.
|
||||
|
||||
```json
|
||||
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null,
|
||||
"payload":{"message_ids":["m_9001","m_9002"]}}
|
||||
```
|
||||
|
||||
### `commands.catalog`
|
||||
|
||||
Fetch the full slash-command catalog (for the `Menü` bottom sheet).
|
||||
|
||||
```json
|
||||
{"type":"commands.catalog","id":21,"payload":{}}
|
||||
```
|
||||
|
||||
### `commands.complete`
|
||||
|
||||
Autocomplete for a typed `/prefix`.
|
||||
|
||||
```json
|
||||
{"type":"commands.complete","id":22,"payload":{"prefix":"/mod"}}
|
||||
```
|
||||
|
||||
### `agent.stop`
|
||||
|
||||
Stop the current agent turn (abort generation / tool execution).
|
||||
|
||||
```json
|
||||
{"type":"agent.stop","id":23,"chat_id":"android:default","thread_id":null,"payload":{}}
|
||||
```
|
||||
|
||||
### `agent.steer`
|
||||
|
||||
Inject a steering message mid-turn (redirects the agent without a new turn).
|
||||
|
||||
```json
|
||||
{"type":"agent.steer","id":24,"chat_id":"android:default","thread_id":null,
|
||||
"payload":{"text":"Actually, focus on the error case."}}
|
||||
```
|
||||
|
||||
### `sync`
|
||||
|
||||
Reconnect catch-up. Replays **undelivered outbox frames** (frames sent while
|
||||
this device was offline). Does NOT load full history — use `history` for that.
|
||||
|
||||
```json
|
||||
{"type":"sync","id":19,"payload":{"cursor":1042}}
|
||||
// server replays outbox frames with cursor > 1042, then {"type":"sync.done","id":19,"payload":{"cursor":1099}}
|
||||
```
|
||||
|
||||
### `fcm.register`
|
||||
|
||||
Update push token.
|
||||
|
||||
```json
|
||||
{"type":"fcm.register","payload":{"fcm_token":"<new>","ntfy_topic":"<topic>"}}
|
||||
```
|
||||
|
||||
### `ping`
|
||||
|
||||
Keepalive. `{"type":"ping","payload":{"ts":1724000000000}}` → `pong`.
|
||||
|
||||
## Ordering & reliability
|
||||
@@ -387,4 +486,4 @@ Keepalive. `{"type":"ping","payload":{"ts":1724000000000}}` → `pong`.
|
||||
- Anything not delivered live goes to the **outbox** and is replayed by `sync`.
|
||||
- **Broadcast:** channel directory events (`channel.*`) and read-receipts are
|
||||
pushed to **all** connected devices for that gateway (no subscribe step).
|
||||
- Requests get exactly one response or `error` (matched by `id`).
|
||||
- Requests get exactly one response or `error` (matched by `id`).
|
||||
@@ -38,12 +38,13 @@
|
||||
"reply_to": { "type": "string" },
|
||||
"model": { "type": "string" },
|
||||
"tokens": { "type": "integer" },
|
||||
"runtime": { "$ref": "#/definitions/runtime" },
|
||||
"ts": { "type": "integer", "description": "epoch millis" }
|
||||
}
|
||||
},
|
||||
"message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } },
|
||||
"message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } },
|
||||
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "ts": { "type": "integer" } } },
|
||||
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "runtime": { "$ref": "#/definitions/runtime" }, "ts": { "type": "integer" } } },
|
||||
"message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } },
|
||||
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
|
||||
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
|
||||
@@ -62,7 +63,7 @@
|
||||
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
|
||||
"pong": { "payload": { "ts": { "type": "integer" } } },
|
||||
"sync.done": { "payload": { "cursor": { "type": "integer" } } },
|
||||
"history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } },
|
||||
"history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "runtime": {"$ref":"#/definitions/runtime"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } },
|
||||
"media.pull.end": { "payload": { "ok": { "type": "boolean" } } },
|
||||
"media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } },
|
||||
"commands.catalog": { "description": "Response to a commands.catalog request: the gateway's slash-command catalog for the app's '/' drawer. Derived from hermes' COMMAND_REGISTRY (gateway-available subset) plus plugin-registered commands. The app fuzzy-matches the typed prefix client-side.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Canonical command with leading slash, e.g. \"/new\"." }, "description": { "type": "string" }, "args_hint": { "type": "string", "description": "Argument placeholder, e.g. \"[name]\"; empty when none." }, "category": { "type": "string", "description": "Registry category (Session, Configuration, Tools & Skills, Info, Exit, Plugin)." }, "aliases": { "type": "array", "items": { "type": "string" }, "description": "Alternative names with leading slash, e.g. [\"/reset\"] for /new." } } } } } }
|
||||
@@ -93,7 +94,8 @@
|
||||
"definitions": {
|
||||
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
|
||||
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."}, "automation": {"type":"boolean","description":"Optional; true when the channel is an automation channel (read-only for the user; only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. Never set on the default channel."} } },
|
||||
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } }
|
||||
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } },
|
||||
"runtime": { "type": "object", "description": "Structured runtime-metadata footer (app-controlled display). The gateway ALWAYS sends it on final assistant messages; whether/what is shown is a per-app setting (Settings -> Runtime footer), NOT a hermes config. All keys optional; absent when the data is unavailable (e.g. local models have no cost).", "properties": { "model": {"type":"string","description":"Bare model id, vendor prefix dropped (gpt-5.4)."}, "context_pct": {"type":"integer","description":"Last-call context occupancy, 0-100."}, "cwd": {"type":"string","description":"Home-relative working dir (~)."}, "latency": {"type":"number","description":"Wall-clock turn duration, seconds."}, "cost": {"type":"number","description":"Turn cost, USD."} } }
|
||||
},
|
||||
"x-planned-frames": [
|
||||
{ "name": "picker.model", "direction": "server_to_app", "note": "Model/provider picker prompt. Planned, not implemented." },
|
||||
|
||||
Reference in new issue
Block a user