16 KiB
The Hub Inbound API (v1)
Purpose: one frozen interface from every way in to the Hub. Applies to: phone app, ESP32 voice, dsh chat, admin app, console portal. Status: frozen. Build the Hub server and every client to this document. Decided: 2026-10-10.
hub_brief.md is the source of truth for the design. This document freezes the
wire format. If this document and the brief disagree, the brief wins.
1. Service language (decided)
Go 1.25.
Reason (one line): The Hub is a small, always-on gateway on the fast path, so a single static Go binary gives hard timeouts, no runtime dependency drift, instant start, and a tiny container, and nothing in the Hub needs the Python data or ML stack because every model call goes through OmniRoute over HTTP.
Build notes:
- Standard library
net/httpwithcontextdeadlines. Small dependency list. - Multi-stage Docker build. Run on a static or distroless base image.
- Deploy as one container on
192.168.20.13(the agreed host). - One binary, one port, one folder. Do not merge a destination into the Hub.
Go 1.25 is available from nixpkgs (a derivation is already in the local store). Docker 29 runs on the build host.
2. Two different /ask endpoints
The name /ask appears twice. They are different services on different ports.
Do not confuse them.
| Endpoint | Caller | Server | Defined in |
|---|---|---|---|
POST /ask |
A way in (phone, voice, chat, admin) | The Hub | This document |
POST /ask |
The Hub | A data project (email, school, internet control) | docs/ask-contract.md |
This document freezes only the Hub endpoint. The Hub sends the project
endpoint with the shape in docs/ask-contract.md.
3. Transport and authentication
Base
- Base URL:
http://192.168.20.13:8788(the allocated Hub port,HUB_PORT; recorded indocs/outline/02-config-and-env.md). - Plain HTTP on the private LAN for v1. Put a TLS reverse proxy in front later.
- Request and response body: JSON,
application/json; charset=utf-8. - Maximum request body: 64 KiB. Larger bodies get
413.
Authentication
Every request carries a bearer token, except GET /health:
Authorization: Bearer <token>
- One token per client, not per person. Store each token in
~/.config/environment.d/10-secrets.conf. - The Hub holds the accepted tokens. It compares them in constant time.
- The token names the device or channel. The token does not name the person.
| Env key | Client |
|---|---|
HUB_TOKEN_PHONE |
Phone app. |
HUB_TOKEN_ESP32 |
House voice (ESP32, "Hey Jervis"). |
HUB_TOKEN_DSH |
dsh chat. |
HUB_TOKEN_CONSOLE |
Console portal. |
HUB_TOKEN_ADMIN |
Admin app. The only trusted door. |
Auth matrix
| Route | Accepted tokens |
|---|---|
POST /ask |
Any client token. |
POST /approve |
HUB_TOKEN_ADMIN only. |
GET /health |
None. |
Voice is shared, not trusted
Voice is open to everyone in the house. The ESP32 token proves the device, not the speaker. The Hub never treats a voice request as proof of identity. A restricted action always needs the admin app.
One identity
There is one identity: Sam. The asker field must be "sam". Any other value
gets 422 unsupported_asker. Keep the field so a second person can be added
later.
4. Common response rules
Success and partial success
A well-formed, authorized, processed request returns HTTP 200, even when the
Hub cannot route the request. The top-level status field says what happened.
status |
Meaning |
|---|---|
ok |
A reply is ready. |
needs_clarification |
The Hub needs one short answer. answer holds the question. |
needs_approval |
A Hub-owned approval is required. approval holds the id. |
no_match |
The Hub cannot route the request. |
refused |
The request is a high-consequence control. The Hub does not do it. |
unavailable |
A destination or dependency is down. |
Error envelope
An error uses a non-200 status and this shape:
{
"status": "error",
"error": {
"code": "bad_request",
"message": "Field 'text' is required.",
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3"
}
}
message is one short sentence. It is safe to speak aloud. It holds no secret,
no internal path, and no stack trace.
HTTP status and error codes
| HTTP | code |
Use |
|---|---|---|
| 400 | bad_request |
Bad JSON, missing field, or bad enum value. |
| 401 | unauthorized |
Missing or invalid bearer token. |
| 403 | forbidden |
Valid token, wrong route. For example, a non-admin calls /approve. |
| 403 | source_mismatch |
The source field does not match the token. |
| 404 | not_found |
Unknown path, or unknown approval_id. |
| 409 | not_hub_owned |
The approval belongs to another service. |
| 409 | already_decided |
The approval was already decided. |
| 409 | approval_expired |
The approval passed its expires time. |
| 413 | payload_too_large |
Body over 64 KiB. |
| 415 | unsupported_media_type |
Body is not application/json. |
| 422 | unprocessable |
Valid JSON, bad content. For example, empty text. |
| 422 | unsupported_asker |
asker is not "sam". |
| 429 | rate_limited |
Too many requests. See Retry-After. |
| 500 | internal |
Unexpected Hub fault. |
| 502 | destination_error |
A destination failed to answer. |
| 503 | unavailable |
A dependency is down (OmniRoute, MQTT). |
| 504 | timeout |
A deadline passed. |
Rate limit
Cap each token at 30 requests per minute. On breach, return 429 with a
Retry-After header in seconds. This protects the Hub. It is not a security
control.
Versioning
Add fields. Do not remove or rename a field. Clients must ignore unknown fields.
A breaking change needs a new path, for example /ask/v2, /approve/v2, or
/health/v2.
5. POST /ask
A way in sends words to the Hub. The Hub runs the five steps: hear, understand, check, route, answer. A way in needs only this route to ask.
Request
{
"text": "what does Harry have on today?",
"asker": "sam",
"source": "esp32",
"person": "harry",
"want": "speech",
"device": "kitchen-esp32",
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3"
}
| Field | Required | Type | Meaning |
|---|---|---|---|
text |
yes | string | The words the user said, or typed. 1 to 1000 characters. |
asker |
yes | string | Who asked. Must be "sam" in v1. |
source |
yes | enum | Where it came from. See below. |
person |
no | string | Who the request is about. Defaults to asker. |
want |
no | enum | Reply shape the caller prefers: text, speech, both. Default comes from source. |
device |
no | string | Device name, for speech routing. |
request_id |
no | string | Client id. The Hub makes one when absent. |
source values: esp32, phone, chat, console, admin.
The token must belong to the source. For example, HUB_TOKEN_DSH cannot claim
source: "admin". A mismatch gets 403 source_mismatch.
Response — a normal reply
{
"status": "ok",
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3",
"answer": "Harry has maths and a music lesson today.",
"shape": "speech",
"speech": { "text": "Harry has maths and a music lesson today.", "target": "house" },
"route": { "kind": "project", "project": "compass_nhs" },
"approval": null,
"clarify": null,
"expires": "2026-10-10T09:00:00Z"
}
| Field | Required | Meaning |
|---|---|---|
status |
yes | See the table in section 4. |
request_id |
yes | Echo of the request id, or a new one. |
answer |
yes | One short sentence. Safe to speak aloud. |
shape |
yes | text, speech, notification, or none. |
speech |
no | TTS detail. Present when shape is speech. |
route |
no | What the Hub did. For logging and debug. |
approval |
no | Present when status is needs_approval. |
clarify |
no | Present when status is needs_clarification. |
expires |
no | When the answer stops being true. |
shape values:
text— text only. Chat and console.speech— the Hub also speaks the reply.notification— a phone or desktop notification.none— no reply body. The action ran.
speech.target values: house (house speakers), phone (the asking phone),
none.
route.kind values: direct (Home Assistant, speakers), project (a data
project through /ask), fanout (several projects), paperclip, clarify,
no_match, refused.
Response — the Hub needs a short answer
{
"status": "needs_clarification",
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3",
"answer": "School or email?",
"shape": "speech",
"speech": { "text": "School or email?", "target": "house" },
"route": { "kind": "clarify" },
"clarify": { "question": "School or email?", "options": ["school", "email"] }
}
Response — a Hub-owned approval is required
A slow, recorded action (a Paperclip job) needs a yes. The Hub does not run it yet. It returns an approval id.
{
"status": "needs_approval",
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3",
"answer": "I need a yes on the admin app to file that job.",
"shape": "speech",
"speech": { "text": "I need a yes on the admin app to file that job.", "target": "house" },
"route": { "kind": "paperclip" },
"approval": {
"approval_id": "ap_01J8Z3K4M5N6P7Q8R9S0T1V2W3",
"action": "paperclip_job",
"summary": "File a job: plan the kitchen lights.",
"reason": "A slow, multi-step job needs a yes.",
"expires": "2026-10-10T09:10:00Z"
}
}
approval.action values in v1: paperclip_job. Add values later. Never remove
one.
Response — a high-consequence control
The Hub does not route a control action. It refuses and points at the admin app.
{
"status": "refused",
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3",
"answer": "Use the admin app to change internet limits.",
"shape": "speech",
"speech": { "text": "Use the admin app to change internet limits.", "target": "house" },
"route": { "kind": "refused", "reason": "high_consequence_control" }
}
Response — no match
{
"status": "no_match",
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3",
"answer": "I do not know where to send that.",
"shape": "text",
"route": { "kind": "no_match" }
}
Behaviour rules
- The Hub never parses a project's data. It forwards the question.
- The Hub never changes the meaning of an answer. It only changes the shape.
- The Hub never places an LLM in the path of a fast or consequential action (brief rule 4). Routing uses an LLM. The action call does not.
- One project down is not an outage. Return
status: "unavailable"and say so. - Every request is logged. See section 10.
6. POST /approve
The admin app answers "Did you ask for this?". This route records one yes or no for a Hub-owned approval and then runs the approved action.
In v1 the only Hub-owned approval is a Paperclip job. The Hub files the job once, then returns the result.
Request
{
"approval_id": "ap_01J8Z3K4M5N6P7Q8R9S0T1V2W3",
"decision": "approve",
"asker": "sam",
"device": "sam-phone"
}
| Field | Required | Type | Meaning |
|---|---|---|---|
approval_id |
yes | string | The id from the /ask response. |
decision |
yes | enum | approve or deny. |
asker |
yes | string | Who decided. Must be "sam" in v1. |
device |
no | string | The admin device. |
Response
{
"status": "ok",
"approval_id": "ap_01J8Z3K4M5N6P7Q8R9S0T1V2W3",
"decision": "approve",
"result": {
"kind": "paperclip_job",
"status": "filed",
"detail": "Issue filed."
},
"answer": "Filed the job."
}
result.status values: filed, denied, failed.
Rules
- An approval is single use. A second decision gets
409 already_decided. - An unknown id gets
404 not_found. - An id owned by another service gets
409 not_hub_owned. The admin app must call that service directly. - An expired approval gets
409 approval_expired. The user must ask again. - Only
HUB_TOKEN_ADMINmay call this route.
Parental controls are not here
A parental control change is a high-consequence control. The Hub does not execute it (brief rule 11). The admin app shows the confirmation and calls the internet control service directly. This route is not in that path.
7. GET /health
Liveness only. No authentication.
Response — healthy
{
"ok": true,
"service": "hub",
"version": "1",
"uptime_s": 4321,
"checks": {
"omniroute": "ok",
"mqtt": "ok",
"log": "ok"
}
}
Response — degraded
{
"ok": false,
"service": "hub",
"version": "1",
"uptime_s": 4321,
"checks": {
"omniroute": "ok",
"mqtt": "down",
"log": "ok"
}
}
HTTP 200 when ok is true. HTTP 503 when ok is false.
Each checks value is ok, down, or unknown. A check never blocks the
health reply for longer than 1 second. The health reply holds no secret and no
personal data.
8. Timeouts
Every timeout is a hard cap. The Hub cancels the work when the cap passes.
| Step | Cap |
|---|---|
| Read the request body | 1 s |
| Routing LLM call (OmniRoute) | 3 s |
One project /ask call |
3 s |
| Fanout (all projects, in parallel) | 4 s |
| One destination command (Home Assistant, MQTT) | 2 s |
POST /ask total |
8 s |
POST /approve total |
5 s |
GET /health total |
1 s |
House TTS is out of band. The Hub starts it and does not wait for the speakers. Phone TTS is part of the reply.
Data project rules stay as written in docs/ask-contract.md: a project answers
in under 3 seconds, or it returns answer: null.
9. Reply shape and TTS target
The want field is a preference. The Hub picks the final shape. It uses source
and want:
source |
Default shape |
Default speech.target |
|---|---|---|
esp32 |
speech |
house |
phone |
speech |
phone |
chat |
text |
none |
console |
text |
none |
admin |
text |
none |
want: "both" returns text and adds speech. want: "text" never speaks.
A spoken reply is always one short sentence.
This settles the open question "when does phone TTS get used?": the Hub speaks on
the asking phone when the source is the phone, and on the house speakers when the
source is the house voice. The caller may override with want. Artemis
transport (stream or file) is still open. See hub_brief.md.
10. Logging
The Hub writes one append-only log (brief rule 13).
One line per request. One line holds:
| Field | Example |
|---|---|
time |
2026-10-10T08:55:03Z |
request_id |
01J8Z3K4M5N6P7Q8R9S0T1V2W3 |
source |
esp32 |
asker |
sam |
text |
what does Harry have on today? |
route |
project:compass_nhs |
status |
ok |
latency_ms |
412 |
shape |
speech |
Rules:
- Append only. Never edit a line.
- Cap the size. Rotate at a fixed size. Keep a fixed number of days.
- No secrets. Never log a token.
- No full personal data. Log the question and the route, not a project's data.
- Never log the
Authorizationheader.
11. Safety confirmation
The three frozen routes are not high-consequence control actions.
| Route | High-consequence control? | Why |
|---|---|---|
POST /ask |
No | Routes a query or a fast command. Refuses a control action with status: "refused". |
POST /approve |
No | Decides a Hub-owned approval only. Files a Paperclip job. Runs no parental control. |
GET /health |
No | Read only. |
A parental control change goes: admin app → internet control service, directly. The Hub is not in that path. See brief rules 7, 8, and 11.
12. Not in v1
POST /notify (a project tells the Hub) is part of the design. Its shape is
defined in docs/ask-contract.md. It is not one of the three frozen Hub-inbound
routes in this document. Freeze it in a later task.
routing.md (the human-edited routing rules) is still open. POST /ask depends
on it. See hub_brief.md, "What is still open", item 5.
The Artemis transport (stream or file) is still open. See hub_brief.md,
"What is still open", item 3.