LifeTrace
Docs/API reference

LifeTrace Engine API Reference

The LifeTrace engine exposes an HTTP API on port 9030 (configurable via the PORT env var). All endpoints are unauthenticated — the engine is expected to run locally on the user's machine. The one exception is the remote-status server (lifetrace.io/api/heartbeat and /api/status), which authenticates via an auto-generated status key.

All request and response bodies are JSON unless noted. Error responses follow the shape { "error": "message", "detail"?: "additional info" }.

Base URL: http://localhost:9030


Table of contents


Health

GET/health

Overall engine health.

Response

{
  "status": "ok",
  "storage": { "available": true, "dataDir": "/…/LifeTrace" },
  "llm": { "canEmbed": true, "canChat": true, "providers": [...] },
  "corpus": { "items": 50000, "sources": 3 }
}

GET/api/health

Corpus health analysis with six-dimension scoring and gap suggestions.

Response

{
  "overall": 54,
  "dimensions": {
    "volume":   { "score": 82, "detail": { "totalTokens": 450000, "totalItems": 12000 } },
    "modality": { "score": 45, "detail": { "types": [...], "entropy": "0.78" } },
    "dialogue": { "score": 60, "detail": { "dialoguePct": "62.3", "dialogueCount": 7500, "totalCount": 12000 } },
    "temporal": { "score": 70, "detail": { "coveredMonths": 18, "maxGapMonths": 2, "gaps": [...] } },
    "breadth":  { "score": 0,  "detail": { "note": "Requires embedding clustering" } },
    "emotion":  { "score": 40, "detail": { "mean": "0.120", "stdDev": "0.180", "count": 11500 } }
  },
  "gaps": [
    { "dimension": "modality", "priority": "medium", "suggestion": "…", "impact": "+10-20 modality score" }
  ]
}

Sources

GET/api/sources

List all connected data sources.

Response: Array of source objects.

Available source types: imessage, gmail, google_calendar, apple_photos_location, google_takeout_location, local_file.

[
  {
    "id": "uuid",
    "type": "imessage",
    "name": "iMessage",
    "status": "connected",
    "config": { "...": "source-specific" },
    "tags": ["personal"],
    "itemCount": 48234,
    "lastSyncAt": "2026-04-17T12:00:00Z",
    "lastSyncCursor": null,
    "consentType": "exported_by_owner",
    "consentTimestamp": "2026-04-10T00:00:00Z",
    "createdAt": "…",
    "updatedAt": "…"
  }
]

POST/api/sources

Create a new source. Typically called programmatically (Gmail/Codex OAuth flows call this; user adds iMessage and local files via the dashboard UI).

Request

{
  "type": "imessage",                    // required: local_file | gmail | imessage
  "name": "iMessage",                    // required
  "config": { "dbPath": "/optional" },   // source-specific
  "consentType": "exported_by_owner",    // defaults to "exported_by_owner"
  "tags": ["personal"]                   // defaults to ["personal"]
}

Response: Created source object (201 implicit via 200).

POST/api/sources/calendar-from-gmail

Create a Google Calendar source reusing the OAuth tokens from an existing Gmail source. The engine probes the Calendar API first; if the Gmail token lacks Calendar scope, returns 400 { "error": "needs_reauth", "message": "…" } so the UI can trigger a reconnect.

Request

{ "gmailSourceId": 3, "tags": ["personal"], "name": "Google Calendar ([email protected])" }

Response: Created Google Calendar source object. tags defaults to the Gmail source's tags when omitted.

PUT/api/sources/:id

Update a source's name or tags.

Request

{ "name": "Work Email", "tags": ["work"] }

DELETE/api/sources/:id

Remove a source, its corpus database, and all ingestion logs. Irreversible.

POST/api/sources/:id/ingest

Trigger ingestion for a source. Non-blocking — returns immediately, work continues in background. Poll /api/sync/status or /api/sources to observe progress.

Response

{ "message": "Sync started", "status": "syncing" }

Corpus

GET/api/corpus

List corpus items, paginated.

Query params: ?page=1&limit=20

Response

{
  "total": 200000,
  "page": 1,
  "pages": 10000,
  "items": [
    {
      "id": "uuid",
      "sourceId": "uuid",
      "sourceType": "imessage",
      "authoredBy": "self",
      "contentSummary": "preview text…",
      "occurredAt": "2026-04-17T12:34:00Z",
      "isDialogue": true,
      "threadId": "chat-id",
      "metadata": { "…": "…" },
      "embedded": true
    }
  ]
}

GET/api/corpus/:id

Get a single corpus item including full contentText.

GET/api/corpus/rhythm

Day-of-week × hour-of-day activity heatmap (self-authored items only). Used by the dashboard to show "when you communicate."

Query params: ?tags=personal,work

Response

{
  "matrix": [[0, 0, ...], ...],          // 7 rows × 24 cols
  "peak": 842,                           // highest cell value
  "peakCell": { "dow": 3, "hour": 14, "count": 842 }
}

GET/api/corpus/constellation

Aggregated data for the trace constellation visualization. Returns one point per (day × source_type × authored_by) combination.

Query params: ?tags=personal

Response

{
  "points": [
    { "day": "2026-04-17", "sourceType": "imessage", "authoredBy": "self", "count": 47 }
  ],
  "range": { "start": "2012-01-15", "end": "2026-04-17" },
  "sourceTypes": { "imessage": 180000, "gmail": 22000 },
  "totalItems": 202000
}

GET/api/corpus/breakdown

Summary stats for the corpus: totals per source type, per author, date range, monthly timeline.


POST/api/chat

Non-streaming persona chat. Takes a query, retrieves top-K memories via vector search, builds a grounded prompt, calls the configured chat provider.

Query params: ?name=Matt (user's name, used in persona prompt; defaults to "the user")

Request

{ "query": "What did I say about Denver?", "history": [] }

Response

{
  "response": "You mentioned pushing the Denver trip to May in a message to Sarah on April 15.",
  "memories": [
    { "id": "…", "score": 0.87, "sourceType": "imessage", "occurredAt": "…" }
  ],
  "grounded": true
}

POST/api/chat/stream

Streaming chat with Server-Sent Events. Persists the conversation to a session.

Request

{ "query": "…", "sessionId": "uuid-or-omit-for-new" }

Response: text/event-stream emitting data: {...}\n\n events with { token, done, memories }.

POST/api/search

Semantic search over the corpus without the persona prompt.

Request

{ "query": "apartment", "topK": 10 }

Response: Array of memory objects with { id, text, sourceType, occurredAt, metadata, score }.


Chat sessions

GET/api/chat/sessions

List saved chat sessions.

POST/api/chat/sessions

Create a new session.

Request: { "title"?: "Optional title" }

GET/api/chat/sessions/:id

Get a session with its message history.

PUT/api/chat/sessions/:id

Update session title.

Request: { "title": "New name" }

DELETE/api/chat/sessions/:id

Delete a session and all its messages.


Embeddings

GET/api/embed/status

Current embedding progress and provider availability.

Response

{
  "total": 50000,              // self-authored items eligible for embedding
  "embedded": 12000,
  "pending": 38000,
  "canEmbed": true,
  "ollamaDetected": true
}

POST/api/embed

Embed a batch of unprocessed items (50 per call). Returns counts.


Sync

POST/api/sync

Trigger sync across all active sources. Non-blocking — returns immediately.

Response: { "message": "Sync started", "status": "syncing" } or { "message": "Already syncing", "status": "syncing" }

GET/api/sync/status

Current sync manager state.

Response

{
  "syncing": true,
  "currentSource": "iMessage",
  "currentSourceId": "uuid",
  "queue": ["Gmail"],
  "lastCheck": "2026-04-17T12:00:00Z"
}

Providers

Providers are LLM configurations (Ollama, OpenAI, Anthropic, Google, Codex, or any OpenAI-compatible endpoint).

GET/api/providers

List configured providers. Secrets are redacted (API keys truncated to first 8 chars, OAuth tokens replaced with { connected: true }).

POST/api/providers

Add a provider.

Request

{
  "type": "openai",              // ollama | openai | openai-codex | openai-compatible | anthropic | google
  "name": "OpenAI",
  "config": {
    "apiKey": "sk-...",          // required for non-Ollama
    "baseURL": "…",              // required for openai-compatible
    "model": "gpt-5.4",
    "embedModel": "text-embedding-3-small"
  },
  "useForChat": true,
  "useForEmbeddings": false
}

PUT/api/providers/:id

Update a provider. Config fields are merged (not replaced) so OAuth tokens are preserved.

DELETE/api/providers/:id

POST/api/providers/:id/test

Send a test prompt to verify the provider works.

Response: { "success": true, "response": "hello" } or { "success": false, "error": "…" }

PUT/api/providers/:id/default

Mark a provider as default for a role.

Request: { "role": "chat" } or { "role": "embeddings" }


Authentication (Gmail, Codex)

Gmail OAuth

GET /api/auth/gmail/status

Check whether Google OAuth credentials are configured.

Response: { "configured": true }

POST /api/auth/gmail/credentials

Save Google OAuth client credentials (user provides these from a Google Cloud project they create).

Request: { "clientId": "…", "clientSecret": "…" }

GET /api/auth/gmail

Initiate OAuth flow. Redirects the browser to Google's consent screen.

GET /api/auth/gmail/callback

Google redirects here with a code. The engine exchanges the code for tokens, stores them, creates a Gmail source.

GET /api/auth/google-calendar

Initiate the Calendar OAuth flow using the same shared Google client as Gmail. Optional query params ?sourceId=…&name=… are round-tripped via state so the callback can link the new Calendar source to the originating Gmail source. Returns 400 if Google OAuth credentials have not been configured yet (connect Gmail first).

Codex OAuth (device code flow)

GET /api/auth/codex/status

Always returns { "configured": true } — uses OpenAI's public device-code client ID.

POST /api/auth/codex/start

Start the device code flow.

Response

{
  "sessionId": "uuid",
  "userCode": "ABCD-1234",
  "verificationUrl": "https://auth.openai.com/codex/device",
  "interval": 5
}

GET /api/auth/codex/poll/:sessionId

Poll for authorization. Returns { "status": "pending" | "authorized" | "expired" | "error" }. On authorized, includes providerId of the newly created Codex provider.


Journal

GET/api/journal

List recent entries.

Query params: ?limit=30&offset=0 (limit capped at 90)

Response

{
  "enabled": true,
  "enabledAt": "2026-04-10",
  "entries": [
    {
      "date": "2026-04-17",
      "content": "You spent the morning emailing…",
      "userAdditions": "Also: dentist appointment.",
      "stats": { "totalItems": 42, "sent": 15, "received": 27, "sourceBreakdown": { "imessage": 30, "gmail": 12 }, "contactCount": 6, "topContacts": [...] },
      "status": "ready",            // pending | generating | ready | empty | failed
      "model": "gpt-5.4",
      "generatedAt": "2026-04-18T06:00:00Z",
      "regeneratedCount": 0,
      "error": null
    }
  ],
  "total": 15,
  "limit": 30,
  "offset": 0
}

GET/api/journal/:date

Get a single day's entry. :date must be YYYY-MM-DD.

POST/api/journal/:date/generate

Force (re)generation. Requires the journal feature to be enabled. Returns { date, status, content?, stats, error? }.

PUT/api/journal/:date

Save user-written additions.

Request: { "userAdditions": "…" }

DELETE/api/journal/:date

GET/api/journal/settings

Current feature toggle state.

Response: { "enabled": true, "enabledAt": "2026-04-10" }

POST/api/journal/settings

Enable or disable the feature.

Request: { "enabled": true }

On first enable, enabled_at is set to today. Forward-only: only days on or after enabledAt are candidates for automatic generation.

GET/api/journal/range

Earliest and latest dates with corpus traces, plus how many entries already exist. maxDate is capped at today (future calendar invites do not leak in).

Response: { "minDate": "2011-01-31", "maxDate": "2026-04-25", "totalDays": 1844, "existingEntries": 293, "today": "2026-04-25" }

POST/api/journal/backfill

Generate journal entries across a date range. Walks newest-first so recent days surface quickly. Returns immediately; poll status for progress.

Request: { "from": "2026-01-01", "to": "2026-04-24", "skipExisting": true }

  • skipExisting: true — generate only days that don't already have an entry. Cheap, additive (the Fill missing days UI button).
  • skipExisting: false — overwrite every existing entry in the range (the Regenerate all button). Confirms in the UI because it's N LLM calls.

Future dates and from > to are rejected with a 400.

GET/api/journal/backfill/status

Live job state including phase, processed/total, generated/empty/skipped/failed counts, current date, and timestamps.

POST/api/journal/backfill/cancel

Signals the running backfill loop to stop after the current item. Returns { status: "cancelling" } or { status: "not-running" }.

GET/api/journal/profile

Read the persistent "About you" profile that's injected into every journal prompt.

Response: { "profile": "I'm Maya, a senior PM at Helio Labs..." }

PUT/api/journal/profile

Replace the profile.

Request: { "profile": "..." }

POST/api/journal/profile/suggest

Synthesize a starter profile from the user's existing journal entries and top contacts. Hits the LLM (~10s) and returns a draft for the user to review and save.

Response: { "profile": "- I'm Matthew Shaxted...\n- ..." }

GET/api/journal/weekly

List weekly digest entries (newest first).

Query: limit (default 52, max 200), offset.

GET/api/journal/weekly/:weekKey

Single weekly digest. weekKey is YYYY-Www (ISO week), e.g. 2026-W17.

POST/api/journal/weekly/:weekKey/generate

Generate or regenerate a weekly digest. Empty if fewer than 3 ready daily entries that week.

GET/api/journal/monthly

List monthly digests. limit defaults to 24.

GET/api/journal/monthly/:monthKey

Single monthly digest. monthKey is YYYY-MM.

POST/api/journal/monthly/:monthKey/generate

Generate or regenerate a monthly digest.

POST/api/journal/digests/backfill

Sweep every period (week + month) that has enough daily entries and generate/refresh its digest. Returns immediately; poll status.

GET/api/journal/digests/backfill/status

Live state of the digest backfill, including phase (weekly | monthly).

GET/api/journal/search?q=...

LIKE-substring match against daily_entries.content. Returns up to 50 matching days with a short snippet around the match plus tags.

Response:

{
  "q": "wedding",
  "total": 3,
  "results": [
    { "date": "2026-04-21", "snippet": "...wedding day folded around real life...", "tags": ["wedding", "family"] }
  ]
}

GET/api/journal/tags

Aggregated tag occurrences across all daily entries. Used by the Threads tab.

Query: days (rolling window in days; omit for all-time), tags (comma-separated source-tag filter).

Response:

{
  "from": "2025-07-07",
  "to": "2026-04-25",
  "missingTags": 0,
  "tags": [
    { "tag": "family", "totalCount": 260, "days": [{ "date": "2026-04-21", "count": 1 }, ...] }
  ]
}

POST/api/journal/backfill-tags

Re-extract tags for any ready entry whose stats.tags is empty. Used after introducing the tag feature; usually a one-time operation.


Locations

GET/api/locations

Combined location items from Google Timeline + Apple Photos. Each item includes raw lat/lng plus the cached reverse-geocoded place name (if backfill has run).

Query: from (YYYY-MM-DD), to, tags (comma-separated source-tag filter).

Response:

{
  "from": "2026-02-21",
  "to": "2026-04-25",
  "count": 79,
  "visits": [
    {
      "id": "uuid",
      "sourceId": "uuid",
      "sourceType": "google_takeout_location",
      "sourceTags": ["personal"],
      "occurredAt": "2026-04-22T16:32:16Z",
      "date": "2026-04-22",
      "lat": 41.91,
      "lng": -87.65,
      "kind": "place",
      "name": "Lincoln Park, Chicago",
      "address": "...",
      "durationMinutes": 138,
      "photoCount": 0,
      "geo": { "placeName": "Lincoln Park, Chicago", "city": "Chicago", "region": "Illinois" }
    }
  ]
}

POST/api/locations/geocode

Reverse-geocode every uncached location coordinate via Nominatim (1 req/sec, persistent SQLite cache). Auto-chains into corpus enrichment when complete: rewrites every location item's content_text with the resolved place name and flags items for re-embedding so RAG matches on neighborhood/city queries.

Returns { status: "started" } immediately; poll status for progress.

GET/api/locations/geocode/status

Response: { "running": true, "phase": "geocoding" | "enriching" | "done", "processed": N, "total": N, "enriched": N, "cacheSize": N, "startedAt": "..." }.

POST/api/locations/enrich

Standalone variant that only does the corpus-text rewrite (no fresh geocoding). Useful if you've manually populated the cache and want to apply it to corpus items.


Heartbeat

GET/api/heartbeat/config

Remote status configuration — URL, auto-generated key, last heartbeat timestamp/status.

Response

{
  "enabled": true,
  "url": "https://lifetrace.io/api/heartbeat",
  "defaultUrl": "https://lifetrace.io/api/heartbeat",
  "key": "xyz…",
  "engineId": "matts-macbook",
  "statusUrl": "https://lifetrace.io/status",
  "lastHeartbeatAt": "2026-04-17T12:00:00Z",
  "lastHeartbeatStatus": "ok"
}

PUT/api/heartbeat/config

Enable/disable remote status reporting or set a custom server URL.

Request: { "enabled": true } and/or { "url": "https://custom.example.com/api/heartbeat" }

POST/api/heartbeat/send

Manually send a heartbeat now (useful for testing).

Response: { "sent": true, "lastHeartbeatAt": "…", "lastHeartbeatStatus": "ok" }


Preflight checks

GET/api/preflight/apple-photos

Tells the UI whether the engine can open the macOS Photos library.

Response (success): { "ok": true, "geotaggedCount": 7876, "dbPath": "/Users/.../Photos.sqlite" }

Response (permission denied): { "ok": false, "error": "permission_denied", "message": "...", "instructions": ["Open System Settings", "..."] }

GET/api/preflight/imessage

Check whether the engine can read the iMessage database.

Query params: ?dbPath=/custom/path (optional; default ~/Library/Messages/chat.db)

Response (ok)

{ "ok": true, "messageCount": 58342, "dbPath": "…" }

Response (permission denied)

{
  "ok": false,
  "error": "permission_denied",
  "message": "Cannot read iMessage database…",
  "instructions": ["Open System Settings", "…"],
  "processName": "Terminal",
  "nodePath": "/usr/local/bin/node"
}

Tags

GET/api/tags

List all tags currently in use across sources, with counts. Always includes the defaults (personal, work, family) even if unused.

Response: [{ "tag": "personal", "count": 2 }, { "tag": "work", "count": 1 }]


Relationships

GET/api/relationships

Aggregated relationship data derived from conversational corpus (iMessage, Gmail). Resolves contact names from macOS Address Book when available.

Response

{
  "total": 48,
  "relationships": [
    {
      "threadId": "+15551234567",
      "handleId": "+15551234567",
      "displayName": null,
      "contactName": "Mom",
      "name": "Mom",
      "totalMessages": 2340,
      "sent": 1200,
      "received": 1140,
      "firstMessage": "2014-05-12T…",
      "lastMessage": "2026-04-17T…",
      "durationDays": 4358,
      "daysSinceLastMessage": 0,
      "messagesPerDay": 0.54,
      "reciprocity": 0.51
    }
  ]
}

Storage

GET/api/storage

Disk usage breakdown.

Response

{
  "total": 450000000,        // bytes
  "rootDb": 120000,
  "vectorDb": 180000000,
  "sources": [
    { "id": "uuid", "name": "iMessage", "size": 250000000 }
  ]
}

Remote status server

These endpoints live on lifetrace.io (not the local engine). Documented here because they're part of the LifeTrace API surface.

POST /api/heartbeat (remote)

Engine sends a status snapshot here.

Headers: X-Status-Key: <engine's auto-generated key>

Request

{
  "engineId": "matts-macbook",
  "timestamp": "2026-04-17T12:00:00Z",
  "uptime": 86400,
  "sync": { "syncing": false, ... },
  "corpus": { "totalItems": 200000, "embeddedItems": 50000, "sourceCount": 3 },
  "sources": [{ "name": "iMessage", "type": "imessage", "status": "connected", "itemCount": 180000, "lastSyncAt": "…" }],
  "providers": 2
}

Data is stored per engine identified by sha256(key)[:16]. Different engines with different keys are isolated.

GET /api/status (remote)

Return the latest snapshot for the engine identified by the provided key.

Headers: X-Status-Key: <key>

Response: { "engines": [ <snapshot> ] } (0 or 1 entries)


Error responses

All errors return JSON:

Status Meaning
400 Bad request — missing required fields, invalid date format, etc.
401 Missing X-Status-Key header (remote endpoints)
403 Invalid key (remote endpoints)
404 Resource not found (source, session, date, provider)
500 Internal error — detail field may include exception message

Versioning

The API is currently unversioned (v0). Breaking changes are documented in MAINTAINING.md. Once stable, we'll pin a /v1/ prefix for backward compatibility.

See also

Something out of date? Edit on GitHub