Skip to content
The world does revolve around you.

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:

{ "error": "Invalid integer value for 'days': 'soon'" }

Required string parameters (q for search, name for context) return HTTP 400 if absent:

{ "error": "Missing ?q= parameter" }

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:

curl -s "http://localhost:8089/api/v1/calendar?days=3"
import requests
r = requests.get("http://localhost:8089/api/v1/calendar", params={"days": 3})
r.raise_for_status()
events = r.json()["events"]

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
{
  "birthdays": [],
  "stale_contacts": [],
  "recent_meetings": [],
  "reconnect": [],
  "follow_up": []
}

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:

{
  "transcript": "...",
  "metadata": { "source": "...", "captured_at": "..." }
}

Response: HTTP 202 with a job_id.

{ "job_id": "f1b3...", "status": "queued" }

GET /api/v1/conversation/status/{job_id}#

Poll an in-flight job. Response shape:

{
  "job_id": "f1b3...",
  "status": "queued | running | done | error",
  "result_path": "..."
}

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:

{
  "items": [
    { "type": "...", "payload": { } }
  ]
}

Limits:

Limit Value
Max items per batch 1,000
Max body size 1 MB (configurable via MAX_POST_BYTES)

Response:

{ "accepted": 12, "rejected": 0, "ingest_id": "..." }

Health#

GET /health#

Cheap liveness check.

{ "status": "ok" }

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#