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
- Sources
- Corpus
- Chat and search
- Chat sessions
- Embeddings
- Sync
- Providers
- Authentication (Gmail, Codex)
- Journal
- Locations
- Heartbeat
- Preflight checks
- Tags
- Relationships
- Storage
- Remote status server
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.
Chat and search
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
- Quickstart — install and run the engine
- User guide — comprehensive feature walkthrough
- Maintaining docs — how to keep docs in sync with the code