Local HTTP API#
The Ostler Hub exposes a local HTTP API on localhost:8089 for the
iOS app and other local clients. The API surfaces calendar, people,
email, timeline, and hub-health data.
This page is the reference for that API. For a higher-level walk-through see Configuration and Environment variables.
Base URL#
| Topology | Base URL |
|---|---|
| Same machine | http://localhost:8089 |
| iOS app over Tailscale | http://${TAILSCALE_IP}:8089 |
| iOS app over LAN | http://${HUB_LAN_IP}:8089 |
By default the server binds to 127.0.0.1 (loopback only). To expose it
on every interface (LAN deploys without Tailscale), set
OSTLER_API_BIND=0.0.0.0. This is the only network-binding knob; see
env-vars.md.
Authentication#
The v1 data-retrieval API (the endpoints in this document) has no
authentication on the request itself and relies on local-only or
Tailnet-only reachability for trust. Treat any host that can reach
:8089 as fully trusted.
Note that the pairing surface is separately authenticated. The
/auth/pair/init and /auth/pair/register endpoints (on the
ostler-assistant gateway, not on :8089) use a short-lived pairing
token plus WebAuthn passkey + PRF as the device-trust handshake.
Paired iOS devices reach the data-retrieval API at :8089 only after that
pairing completes and they hold a paired-device session.
If you need to expose the API beyond the local machine, use Tailscale
(recommended) or run a reverse proxy that adds bearer-token auth in
front of the API. The API itself does not validate Authorization
headers in v1.
Path versions#
Each endpoint accepts both an unprefixed legacy path and a versioned
/api/v1/... path. They route to the same handler. Prefer the
versioned form for new clients.
| Versioned | Legacy alias |
|---|---|
/api/v1/people/search |
/people/search |
/api/v1/people/context |
/people/context |
/api/v1/people/stale |
/people/stale |
/api/v1/people/recent |
/people/recent |
/api/v1/people/birthdays |
/people/birthdays |
/api/v1/calendar/today |
/calendar/today |
Common response shapes#
Degraded-graceful contract#
Endpoints that depend on the vector store, graph store, or embedding model degrade rather than 5xx when their backend is unreachable. The response is still HTTP 200 and the shape includes:
{
"<items_key>": [],
"degraded": true,
"reason": "<short backend error>",
"error": "<same as reason, kept for backward compat>"
}
<items_key> is whatever key the endpoint normally returns
(results, contacts, people, items, etc.). Existing keys are
preserved so older clients keep working.
Clients SHOULD inspect degraded and surface a "data partially
unavailable" hint to the user; SHOULD NOT treat HTTP 200 + degraded as
a clean success.
Input validation errors#
Integer query parameters (days, months, limit, hours) are
parsed safely. A bad value returns HTTP 400:
Required string parameters (q for search, name for context) return
HTTP 400 if absent:
HTTP status codes#
| Code | Meaning |
|---|---|
| 200 | Success, including the degraded-graceful case (check degraded) |
| 202 | Accepted (async job submitted) |
| 400 | Bad input – missing required param, malformed integer, malformed JSON body |
| 404 | Unknown path |
| 413 | POST body exceeds MAX_POST_BYTES (default 1 MB) |
| 415 | Unsupported Content-Type on a POST endpoint |
| 500 | Unexpected internal error – degraded contract is the preferred path; a 500 is a bug |
Endpoint reference#
Calendar#
GET /api/v1/calendar#
Calendar events for the next days days, merged from iCloud (CalDAV)
and Google Calendar (where configured). Deduplicated by
(summary, start).
| Query param | Type | Default | Notes |
|---|---|---|---|
days |
int | 7 | Window in days from now |
Each event carries the legacy iCal-style fields plus iOS-friendly aliases:
| Field | Type | Notes |
|---|---|---|
summary |
string | Legacy |
title |
string | Alias of summary |
start, end |
string | iCal-style local time, e.g. 20260428T093000 |
start_iso, end_iso |
string | ISO-8601, e.g. 2026-04-28T09:30:00 |
attendees |
array | List of {name, email, role} |
attendee_names |
array | List of plain strings |
location |
string | iCal LOCATION: value |
source |
string | iCloud or Google Calendar |
Example:
Response:
{
"events": [
{
"summary": "Stand-up",
"title": "Stand-up",
"start": "20260428T093000",
"end": "20260428T100000",
"start_iso": "2026-04-28T09:30:00",
"end_iso": "2026-04-28T10:00:00",
"attendees": [
{"name": "Alice", "email": "[email protected]", "role": "attendee"}
],
"attendee_names": ["Alice"],
"location": "Zoom",
"source": "Google Calendar"
}
],
"count": 1
}
GET /api/v1/calendar/today#
Convenience alias for "events between now and end of day". Same response
shape as /api/v1/calendar.
People#
The People endpoints surface data from the Hub's vector store (semantic search) and graph store (relationships, meetings, birthdays). See the degraded contract for behaviour when those backends are down.
JSON keys use British-English spelling (organisation, how_we_met).
Every record carries a slug suitable as a stable identifier.
GET /api/v1/people/search#
Semantic search across the people collection.
| Query param | Type | Default | Notes |
|---|---|---|---|
q |
string | required | Free-text query |
limit |
int | 5 | Max results |
Returns a role field (short label, suitable for list rows).
{
"query": "fintech",
"results": [
{
"name": "Jane Doe",
"slug": "jane-doe",
"score": 0.812,
"wiki_url": "http://localhost:8044/People/jane-doe/",
"organisation": "Example Corp",
"role": "VP Product",
"last_contact": "2026-02-14"
}
],
"count": 1
}
GET /api/v1/people/context#
Full Person record with identifiers, facts, recent meetings, and the latest relationship-signal observation.
| Query param | Type | Default | Notes |
|---|---|---|---|
name |
string | required | Slug or display name |
A single match returns a flat envelope with the person fields lifted
to the top of the object plus a nested person record. Multiple
matches return {"matches": [...], "count": N} with no flat lift.
Returns a title field (full job title, suitable for detail views) –
note the contrast with /people/search's role.
{
"query": "Jane",
"found": true,
"name": "Jane Doe",
"slug": "jane-doe",
"organisation": "Example Corp",
"title": "VP Product",
"last_contact": "2026-02-14",
"person": {
"name": "Jane Doe",
"slug": "jane-doe",
"person_uri": "urn:pwg:person/jane-doe",
"wiki_url": "http://localhost:8044/People/jane-doe/",
"organisation": "Example Corp",
"title": "VP Product",
"relationship": "colleague",
"how_we_met": "RISE 2024",
"last_contact": "2026-02-14",
"notes": "Co-founder of prior startup.",
"birthday": "1985-06-12"
}
}
GET /api/v1/people/stale#
Contacts not interacted with for at least months months, ordered
most-stale first.
| Query param | Type | Default | Notes |
|---|---|---|---|
months |
int | 3 | Minimum months since last contact |
limit |
int | 5 | Max results |
{
"contacts": [
{
"name": "Pat Wong",
"slug": "pat-wong",
"wiki_url": "http://localhost:8044/People/pat-wong/",
"last_contact": "2025-09-01",
"months_since_contact": 7,
"organisation": "Bank Co"
}
]
}
GET /api/v1/people/recent#
People with a meeting in the last days days, ordered most-recent
first.
| Query param | Type | Default | Notes |
|---|---|---|---|
days |
int | 7 | Window in days |
limit |
int | 5 | Max results |
GET /api/v1/people/birthdays#
Upcoming birthdays within days days.
| Query param | Type | Default | Notes |
|---|---|---|---|
days |
int | 7 | Lookahead window |
{
"people": [
{
"name": "Pierre Dubois",
"slug": "pierre-dubois",
"birthday": "05-12",
"days_until": 19
}
]
}
Email#
GET /api/v1/email/recent#
Recent emails. Returns subject, from, date, and snippet only – never the full body. Backed by the configured Gmail integration.
| Query param | Type | Default | Notes |
|---|---|---|---|
hours |
int | 24 | Lookback window |
limit |
int | 20 | Max results |
{
"emails": [
{
"from": "Pat Wong <[email protected]>",
"subject": "Re: Q3 review",
"date": "2026-04-28T10:14:00Z",
"snippet": "Thanks for sending those over – had a question about..."
}
],
"count": 1
}
Timeline#
GET /api/v1/timeline#
Chronological merge of upcoming calendar events plus past meetings within the window.
| Query param | Type | Default | Notes |
|---|---|---|---|
days |
int | 7 | Window in days |
The legacy items[] shape carries one entry per event with a kind
field (calendar or meeting). The same data is also surfaced under
entries[] mapped to the iOS vocabulary; this is the preferred shape
for new clients.
{
"items": [
{
"kind": "calendar",
"title": "Stand-up",
"start_iso": "2026-04-28T09:30:00",
"attendees": ["Alice"]
}
],
"entries": [
{
"type": "calendar",
"timestamp": "2026-04-28T09:30:00",
"title": "Stand-up",
"subtitle": "Zoom",
"attendees": ["Alice", "[email protected]"]
}
],
"days": 7,
"count": 1
}
entries[].timestamp is normalised to ISO-8601 where parseable;
unparseable strings flow through unchanged so the client can render
them rather than silently nilling.
Suggestions#
GET /api/v1/suggestions#
Composite endpoint for the iOS app's Today view. Three sections plus two aliases:
| Section | Description |
|---|---|
birthdays |
Upcoming birthdays (same shape as /people/birthdays) |
stale_contacts |
Contacts to reconnect with (same shape as /people/stale) |
recent_meetings |
Recent meetings (same shape as /people/recent) |
reconnect |
Alias of stale_contacts |
follow_up |
Alias of recent_meetings |
Per-section failure capture: a section that fails will have its array
present-but-empty plus a sibling <section>_error field. Aliases
mirror the legacy section, so they too will be empty in that case.
Coach observations#
GET /api/v1/coach/recent#
Recent coaching observations from the Hub's local observation database.
| Query param | Type | Default | Notes |
|---|---|---|---|
hours |
int | 168 | Lookback window (default 7 days) |
limit |
int | 20 | Max results |
Reading the observation database requires the database key to be set
on the server process via OSTLER_DB_KEY. If it is not set, this
endpoint returns the degraded-graceful contract.
Conversation processing#
POST /api/v1/conversation/process#
Submit a conversation transcript for asynchronous processing.
| Header | Required | Notes |
|---|---|---|
Content-Type |
yes | Must be application/json |
Body shape:
Response: HTTP 202 with a job_id.
GET /api/v1/conversation/status/{job_id}#
Poll an in-flight job. Response shape:
Ingest#
POST /api/v1/ingest/ios#
Batch upload from the Ostler iOS app. Writes to INGEST_DIR for
downstream pickup.
| Header | Required | Notes |
|---|---|---|
Content-Type |
yes | Must be application/json |
Body shape:
Limits:
| Limit | Value |
|---|---|
| Max items per batch | 1,000 |
| Max body size | 1 MB (configurable via MAX_POST_BYTES) |
Response:
Health#
GET /health#
Cheap liveness check.
GET /health?detailed=1#
Runs reachability checks against the vector store, graph store,
embedding model, the Google Workspace CLI, and the iCloud query script.
Returns degraded if any check fails. The endpoint itself never 5xxs.
{
"status": "degraded",
"checks": {
"vector_store": { "healthy": true },
"graph_store": { "healthy": true },
"embedding": { "healthy": true },
"google_workspace": { "healthy": false, "error": "gws not found" },
"caldav": { "healthy": true }
}
}
GET /api/v1/hub/health#
Source of truth for the iOS app's Hub status pill (Online / Catching up / Offline).
Derives hub_status from service health plus queue depth:
| Status | Meaning |
|---|---|
online |
All services healthy and queue empty |
catching_up |
At least one service healthy, but one is down or queue_depth > 0 |
offline_local |
No upstream service reachable. Distinct from the iOS-app-side "Hub unreachable", which is inferred from a timeout. |
Every sub-check is capped at HUB_CHECK_TIMEOUT_SECONDS (default 2 s)
and runs in parallel so one slow dependency cannot block the response.
The endpoint itself never 5xxs; catastrophic failures return
hub_status: "offline_local" with an error field.
{
"hub_status": "online",
"hub_version": "0.5.9",
"last_sync": "2026-04-24T15:32:01Z",
"queue_depth": 0,
"power_state": "ac",
"services": {
"assistant": { "healthy": true, "pid": 48466 },
"ollama": { "healthy": true, "model": "qwen3.5:9b" },
"storage": { "healthy": true, "containers_up": 9, "containers_expected": 9 },
"caldav": { "healthy": true, "last_refresh": "2026-04-24T15:31:58Z" }
},
"degraded_features": []
}
The model field reflects whichever local model the installer auto-selected for your RAM tier (see first run > AI model selection). Expect to see qwen3.5:9b on a 24-47 GB Mac, qwen3.6:35b-a3b on 48 GB+, or gemma4:e2b on 16-23 GB.
When a service is unhealthy, degraded_features carries the union of
feature keys the iOS app should grey out:
| Service down | Features unavailable |
|---|---|
ollama |
assistant_chat |
assistant |
assistant_chat, email_triage |
storage |
people_search, timeline, wiki_live |
caldav |
calendar_live |
Limits#
| Limit | Default | Override |
|---|---|---|
| Max POST body size | 1 MB (1,048,576 bytes) | MAX_POST_BYTES |
| Per-check health timeout | 2.0 s | HUB_CHECK_TIMEOUT_SECONDS |
| Ingest batch size | 1,000 items | not configurable |
There is no per-IP rate limit in v1; the API is intended for local-only or Tailnet-only use.
See also#
- Configuration – top-level Hub configuration.
- Environment variables – every variable the API reads.
- CLI – the
ostler-*commands that drive the Hub.