# 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/http` with `context` deadlines. 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 in `docs/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:

```json
{
  "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

```json
{
  "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

```json
{
  "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

```json
{
  "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.

```json
{
  "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.

```json
{
  "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

```json
{
  "status": "no_match",
  "request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3",
  "answer": "I do not know where to send that.",
  "shape": "text",
  "route": { "kind": "no_match" }
}
```

### Behaviour rules

1. The Hub never parses a project's data. It forwards the question.
2. The Hub never changes the meaning of an answer. It only changes the shape.
3. 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.
4. One project down is not an outage. Return `status: "unavailable"` and say so.
5. 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

```json
{
  "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

```json
{
  "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

1. An approval is single use. A second decision gets `409 already_decided`.
2. An unknown id gets `404 not_found`.
3. An id owned by another service gets `409 not_hub_owned`. The admin app must
   call that service directly.
4. An expired approval gets `409 approval_expired`. The user must ask again.
5. Only `HUB_TOKEN_ADMIN` may 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

```json
{
  "ok": true,
  "service": "hub",
  "version": "1",
  "uptime_s": 4321,
  "checks": {
    "omniroute": "ok",
    "mqtt": "ok",
    "log": "ok"
  }
}
```

### Response — degraded

```json
{
  "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 `Authorization` header.

## 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.
