Authentication

Every endpoint (except /health and the auth routes themselves) requires a Bearer JWT. Ergen uses passwordless email one-time-password (OTP) authentication — there is no password and no Google sign-in. Access is invite-based.

  1. Submit an access request (POST /api/access-request).
  2. An admin approves the account.
  3. Request a login code (POST /auth/request-otp).
  4. Verify it for a JWT (POST /auth/verify-otp).
  5. Refresh the JWT before it expires (POST /auth/refresh).
Authorization Header
Authorization: Bearer <jwt_token>
POST /api/access-request No auth

Request access to Ergen. Creates a pending account that an admin must approve before login is possible. This is the only sign-up path.

Request Body
{ "name": "Jane Doe", "email": "jane@acme.com", "company": "Acme Corp", "position": "Analyst", "use_case": "Monitoring geopolitical events", "heard_about": "Twitter" }
ParameterTypeDescription
name*stringFull name (max 300 chars)
email*stringWork email (max 300 chars)
company*stringCompany (max 300 chars)
position*stringRole / title (max 300 chars)
use_casestringOptional. Intended use (max 4000 chars)
heard_aboutstringOptional. How you heard about us (max 4000 chars)
Response 200
{ "success": true, "status": "submitted" }
400Missing required field, invalid email, or a field exceeds its length limit
409already_pending (awaiting approval) or already_approved (account active — log in instead)
POST /auth/request-otp No auth

Email a one-time login code to an approved account. The code expires after expires_in seconds; a new code can be requested again after resend_in seconds.

Request Body
{ "email": "jane@acme.com" }
Response 200
{ "success": true, "otp_sent": true, "expires_in": 600, "resend_in": 60 }
400invalid_email — a valid email is required
403pending — account is awaiting admin approval
404not_found — no account; submit an access request first
429cooldown — requested again too soon (see retry_after)
502send_failed — the email could not be delivered
POST /auth/verify-otp No auth

Verify a login code and receive a JWT plus the user profile. Put the returned jwt in the Authorization header on every subsequent request.

Request Body
{ "email": "jane@acme.com", "code": "123456" }
Response 200
{ "success": true, "jwt": "eyJhbGciOi...", "user": { "user_id": "uuid-string", "email": "jane@acme.com", "name": "Jane Doe", "role": "client", "tier": 1, "picture_url": "" } }
400invalid — wrong or missing code (may include attempts_left)
403pending — account is awaiting admin approval
404not_found — no account; submit an access request first
410expired — code expired; request a new one
429too_many_attempts — too many wrong codes
POST /auth/refresh Auth required

Exchange a valid (non-expired) JWT for a fresh one. Works for pending accounts too, so a client can keep a session warm while awaiting approval.

Response 200
{ "jwt": "eyJhbGciOi..." }

Entity Reference Format

Entity references follow the pattern {type}___{Name_With_Underscores} (three underscores as separator).

per
Person
per___Donald_Trump
org
Organization
org___United_Nations
priv
Private company
priv___SpaceX
stock
Publicly traded company
stock___Apple
cnt
Country
cnt___United_States
govactor
Government actor / agency
govactor___Department_of_Defense
grp
Group
grp___NATO
oth
Other
oth___Bitcoin

Error Handling

400Bad request — missing or invalid parameters
401Unauthorized — missing, invalid, or expired JWT
403Forbidden — insufficient role (non-admin accessing admin endpoints)
404Not found — requested resource doesn't exist
409Conflict — resource already exists / in a conflicting state
429Too many requests — usage quota exhausted (see Retry-After header)
500Server error — unexpected failure
Standard Error Format
{ "error": "Description of the error" }
Some endpoints return {"success": false, "error": "..."} instead. See per-endpoint documentation.

Events

GET /api/event/{eventId} Auth required

Retrieve full event data including summary, bookkeeping, and deduplicated actions.

Query ParamTypeDescription
include_archivedboolDefault: false. Include archived event data (only takes effect for callers with archive access — tier 2+/admin).
Response 200
{ "summary": { "EventTitle": "Trade War Escalation", "EventDescription": "...", "EventBroadCategory": "Economic", "GeneralSentiment": 25, "MarketAttention": 70, "MarketImpact": "...", "Actions": [{ "Action": "...", "Importance": 85 }], "Entities": [{ "EntityReference": "per___...", "EventImportance": 90 }] }, "bookkeeping": { ... }, "absorbedEventsData": [ ... ], "pastSummaries": [ ... ], "error": "None" }
The error field contains the literal string "None" on success (not JSON null). There is no success key on this endpoint.
GET /api/event/{eventId}/details Auth required

Get a summarized view: top 5 actions, top 5 entities, description, and market impact. Lighter than the full event endpoint.

Query ParamTypeDescription
unprocessedEventsIncludedboolDefault: false. If the event has no processed summary, fall back to placeholder title/description from its centroid article.
Response 200
{ "success": true, "eventId": "event_123", "eventTitle": "Trade War Escalation", "eventType": "Political", "description": "...", "marketImpact": "...", "sentiment": 25, "attention": 70, "topActions": [{ "action": "...", "importance": 85 }], "topEntities": [{ "entityRef": "per___...", "displayName": "..." }] }
404Event not found
GET /api/event/{eventId}/similar Auth required

Find events semantically similar to a given event, ranked by embedding similarity.

Query ParamTypeDescription
limitintMax results. Default: 20
min_scorefloatMinimum similarity (0–1). Default: 0.4
Response 200
{ "success": true, "count": 12, "events": [ ... ] }
GET /api/event/{eventId}/actions Auth required

Get the deduplicated actions for an event, sorted by importance.

Query ParamTypeDescription
limitintMax actions. Default: 50
include_archivedboolInclude actions from archived sources. Default: true — for callers with archive access (tier 2+/admin), archived actions are returned unless explicitly disabled.
Response 200
{ "success": true, "event_id": "event_123", "source": "summary", "count": 8, "total_in_summary": 8, "actions": [ ... ] }
POST /api/events/batch Auth required

Fetch multiple events in one request. The events object is keyed by event id; ids that don't resolve are listed in missing.

Request Body
{ "event_ids": ["event_123", "event_456"], "include_archived": false }
Response 200
{ "success": true, "count": 1, "events": { "event_123": { ... } }, "missing": ["event_456"], "archive_used": [] }
GET /api/entity-events/{entityRef} Auth required

Get recent events that mention an entity, most-recent first.

Query ParamTypeDescription
limitintMax events. Default: 50
include_inactiveboolInclude deactivated (merged) events. Default: false
include_archivedboolInclude archived events. Default: false
Response 200
{ "success": true, "entity_ref": "per___Donald_Trump", "events": [ ... ], "total_events": 42, "archived_events_included": 0, "concise": true }
POST /api/search-semantic Auth required

Free-text semantic search over events using embedding similarity. Lighter than /api/search — no feed filters, just a query.

Request Body
{ "query": "oil prices", "limit": 5, "min_score": 0.4 }
Response 200
{ "success": true, "count": 5, "events": [ ... ] }
POST /api/article-bodies Auth required

Fetch the raw article text for one or more article ids. The articles object is keyed by article id.

Request Body
{ "article_ids": ["1776269820-25103"] }
Response 200
{ "success": true, "count": 1, "articles": { "1776269820-25103": "Full article body text..." } }
POST /api/actions-between Auth required

Get the actions that directly connect two entities.

Request Body
{ "entity_a": "per___Donald_Trump", "entity_b": "per___Joe_Biden", "include_stagnated": false }
Response 200
{ "success": true, "entity_a": "per___Donald_Trump", "entity_b": "per___Joe_Biden", "include_stagnated": false, "count": 9, "actions": [ ... ] }
POST /api/search-actions Auth required

Semantic search across actions (atomic interactions) rather than whole events.

Request Body
{ "query": "oil", "limit": 20, "min_score": 0.4, "max_age_days": 90 }
Response 200
{ "success": true, "query": "oil", "count": 14, "actions": [ ... ] }
POST /api/search-archive Tier 2+

Search the historical (archived) event store. Requires a tier-2 or higher account.

Request Body
{ "query": "trade war", "filters": { ...feed filters }, "after_date": 1704067200, "before_date": 1735689600, "limit": 20 }
Response 200
{ "success": true, "count": 7, "events": [ ... ] }
403Account tier below 2

Entities

GET /api/loadEntityPage/{entityRef} Auth required

Retrieve entity data including relationships and actions. POST variant allows passing relationship display settings in the request body. Note several keys carry a literal .json suffix.

Response 200
{ "displayName": "Donald Trump", "entityRef": "per___Donald_Trump", "bookkeeping.json": { ... }, "actions.json": [ ... ], "clusterBookkeepingsDict.json": { ... }, "clusterGenInfoDict.json": { ... }, "relationships": { ...keyed by relationship type }, "relationships_raw": [ ... ], "relationships_count": 42, "relationships_index": { ... }, "timeseries": { ... }, "error": "None" }
POST /api/loadEntityPage/{entityRef} Auth required

Same as the GET variant, but accepts custom relationship display settings in the body (same shape as /api/entity-relationships-filtered). Use it to control how the entity's relationships are filtered and sorted on load.

Request Body
{ "relationshipSettings": { "limit": 50, "sort_by": "gate", "filters": { ...same shape as /api/entity-relationships-filtered } } }
Response 200
// same shape as GET /api/loadEntityPage/{entityRef}
GET /api/loadEntitySecondaryData/{entityRef} Auth required

Retrieve secondary data (Wikipedia summary and market data). Call after the main entity page loads for a faster initial render. market is only populated for entities with a stock ticker.

Response 200
{ "entityRef": "stock___Apple", "wikipedia": { "summary": "..." }, "market": { ... } }
GET /api/getEntityLivePricesData/{entityRef} Auth required

Get live price data for an entity with a stock ticker: 60-day hourly candles and 1-year daily candles. Both arrays are empty for entities without a ticker.

Response 200
{ "entityRef": "stock___Apple", "hourly": [{ "timestamp": 1713600000, "open": 150.25, "close": 151.50 }], "daily": [{ "timestamp": 1713600000, "open": 150.25, "close": 153.75 }] }
POST /api/entities/batch Auth required

Get current data for multiple entities in a single request.

Request Body
{ "entityRefs": ["per___Donald_Trump", "stock___Apple"] }
Response 200
{ "success": true, "entities": { "per___Donald_Trump": { "activeEvents": 15, "lastUpdateTime": 1713600000, "entityType": "per", "found": true }, "stock___Apple": { "found": false, "entityType": "stock" } } }
POST /api/entity-search Auth required

Search entities using FTS5 full-text search across names, aliases, and tickers. Page size (limit) capped at 100.

Request Body
{ "query": "trump", "types": ["per", "org"] }
ParameterTypeDescription
query*stringSearch term
typesstring[]Filter by entity type prefix. Omit to search all types.
Query ParamTypeDescription
limitintPage size. Default: 20
offsetintSkip results for paging. Default: 0
Response 200
{ "success": true, "results": [{ "name": "per___Donald_Trump", "displayName": "Donald Trump", "types": { "per": 18 }, "secondaryDetail": { ... }, "score": 25.3, "ticker": "DJT" }], "pagination": { "total": 37, "limit": 20, "offset": 0, "next_offset": 20, "has_more": true } }
GET /api/entity-actions/{entityRef} Auth required

Get the actions an entity participates in, across all events, sorted by importance.

Query ParamTypeDescription
rolestring"both" (default), "source", or "target"
limitintMax actions. Default: 50
include_stagnatedboolInclude stagnated actions. Default: true
include_archivedboolInclude archived sources. Default: false. Requires archive access (tier 2+/admin); ignored (treated as false) for lower tiers.
Response 200
{ "success": true, "entity_ref": "per___Donald_Trump", "role": "both", "count": 50, "actions": [ ... ], "include_stagnated": true, "include_archived": false, "archived_mentions_added": 0 }
POST /api/entity-comentions Auth required

Co-mention metrics for two entities: how often they appear together in event clusters and the combined importance of those mentions.

Request Body
{ "entity_a": "per___Donald_Trump", "entity_b": "per___Joe_Biden" }
Response 200
{ "success": true, "metrics": { "mutual_cluster_count": 14, "cluster_count_a": 120, "cluster_count_b": 98, "importance_product": 0.42, "total_importance_a": 5.1, "total_importance_b": 4.3, "total_articles_mutual_a": 210, "total_articles_mutual_b": 185 } }
400{"success": false, "error": "entity_a and entity_b are required"} when either is missing/blank

Relationships

POST /api/entity-relationships-filtered/{entityRef} Auth required

Get filtered and sorted relationships for an entity. Primary endpoint for the relationship browser.

Request Body
{ "limit": 50, "sort_by": "gate", "offset": 0, "include_both_orientations": true, "filters": { "direction": ["outgoing", "incoming"], "processed_only": false, "min_co_events": 0, "min_gate": 0.0, "min_evidence": 0.0, "pair_types": ["per_cnt"], "counterparty_types": ["per", "priv"], "qualitative_filter": { ... } } }
ParameterTypeDescription
limitintMax relationships to return. Default: 50 (range 1–500)
sort_bystring"gate" (default), "n_co_events", "evidence", or "recency"
offsetintSkip results for paging. Default: 0
include_both_orientationsboolUsed only when filters.direction is absent: true → both, false → outgoing only
filters.directionstring[]"outgoing" and/or "incoming"
filters.processed_onlyboolOnly LLM-processed relationships
filters.min_co_eventsintMinimum shared-event count
filters.min_gatefloatMinimum gate score
filters.min_evidencefloatMinimum evidence score
filters.pair_typesstring[]Filter by pair type (e.g. per_cnt)
filters.counterparty_typesstring[]Filter by counterparty entity type prefixes
Query ParamTypeDescription
limitintPage size. Overrides the body's limit if present.
offsetintSkip results for paging. Default: 0
sort_bystringSort key. Overrides the body's sort_by if present.
Response 200
{ "success": true, "entity_ref": "per___Donald_Trump", "entity_display_name": "Donald Trump", "relationships": [ ... ], "count": 3, "total": 42, "sort_by": "gate", "pagination": { "total": 42, "limit": 3, "offset": 0, "next_offset": 3, "has_more": true } }
400Invalid limit/offset format
GET /api/relationship/{entityA}/{entityB} Auth required

Get comprehensive relationship data between two entities, including actions, processed attributes, and timeseries sentiment data.

Response 200
{ "success": true, "entity_a": "per___Donald_Trump", "entity_b": "per___Joe_Biden", "entity_a_display_name": "Donald Trump", "entity_b_display_name": "Joe Biden", "pair_type": "per_per", "actions": [ ... ], "action_count": 35, "qualitative": { ... }, "qualitative_processed": true, "co_mention_metrics": { ... }, "mutual_event_data": [ ... ], "mutual_event_ids": [ ... ], "mutual_cluster_count": 12, "bucket_table": { ... }, "directional_math": { ... }, "dashboard_url": "...", "relbrowser_url": "...", "timeseries": [{ "datetime": "...", "sentiment": 0.3 }] }
timeseries is null if no actions exist. Pass ?debug=true for verbose timeseries output.
POST /api/relationships-search Auth required

Find relationships involving a single entity (on either side), filtered and sorted. Powers the search page's relationship tab.

Request Body
{ "entity_ref": "per___Donald_Trump", "pair_types": [ ... ], "counterparty_types": [ ... ], "min_co_events": 0, "min_gate": 0.0, "min_evidence": 0.0, "qualitative_filter": { ... }, "limit": 50, "sort_by": "gate" }
Filter params sit at the top level of the body (no filters wrapper) and use snake_case; the key is entity_ref, not entityRef.
Response 200
{ "success": true, "count": 18, "sort_by": "gate", "relationships": [ ... ] }
GET /api/pair-type-schema/{pairType} Auth required

Get the qualitative-attribute schema for a relationship pair-type (e.g. the set of processed attributes valid for a given pair of entity types). Useful for building relationship filters.

Response 200
{ "success": true, "pair_type": "per_biz", "schema": { ... } }
400Unknown pair_type (response lists the valid values)
404Pair-type has no qualitative configuration yet

Paths

Find chains of relationships connecting two entities, optionally through required waypoints.

POST /api/entity-paths Tier 2+
Request Body
{ "entity_a": "per___Donald_Trump", "entity_b": "per___Hillary_Clinton", "max_steps": 4, "firstNPaths": 3, "settings": { "entitiesInBetween": ["cnt___United_States"], "filters": { ... } } }
ParameterTypeDescription
entity_a*stringStart entity reference
entity_b*stringEnd entity reference
max_stepsintMax path length (1–10). Default: 4
firstNPathsintMax paths to return. -1 for all (up to 100). Default: 3
settings.entitiesInBetweenstring[]Waypoints the path must pass through
settings.filtersobjectSame filter options as relationship filtering
Response 200 — paths found
{ "success": true, "found": true, "num_paths": 2, "paths": [{ "path_number": 1, "length": 3, "entities_display": ["Donald Trump", "United States", "Hillary Clinton"], "type": "path" }] }
type is "path" for multi-hop paths or "relationship" for direct connections. segments only populated when waypoints are specified.
400Missing entity_a/entity_b, or max_steps/firstNPaths out of range
403Account tier below 2

StreetQuery

Execute complex graph or chain queries. Define conditional graph patterns to find matching sets of entities. Supports "graph" (nodes with roles + edges) and "chain" (linear entity-relationship steps) query types.

POST /api/streetquery Tier 2+
Request Body — graph example
{ "type": "graph", "nodes": { "ANCHOR": { "role": "anchor", "anchorRef": "stock___Amazon" }, "CEOS": { "role": "result", "filter": { "types": ["per"] } } }, "edges": [{ "from": "ANCHOR", "to": "CEOS", "type": "relationship", "relationshipFilter": { "names": ["ceo"] } }], "output": { "returnNodes": ["CEOS"] } }
Response 200
{ "success": true, "nodes": { "CEOS": [{ "ref": "per___Andy_Jassy", "displayName": "Andy Jassy" }] }, "stats": { "executionTimeMs": 45 } }
400No query provided or invalid query structure
403Account tier below 2
429Quota exceeded (non-admin; weight scales with node+edge count). Body {"error","window","used","cap","resets_at"}

Feeds

Feeds and search share the same filter structure. All filter fields are optional.

Feed Configuration Object
{ "query": "optional text query", "entityConditions": [{ "entity": "per___Donald_Trump", "importance": "any" }], "categories": ["Political", "Economic"], "minSize": 5, "dateRange": { "start": 1710000000, "end": 1713600000 }, "sentimentRange": { "min": -100, "max": 100 }, "attentionRange": { "min": 0, "max": 100 } }
GET /api/initialize Auth required

Load initial dashboard data. Returns saved feed configurations and current feed data.

Query ParamTypeDescription
per_feed_limitintMax events per feed in the initial payload
unprocessedEventsIncludedboolInclude events without a generated summary. Default: false
Response 200
{ "success": true, "savedFeeds": { "My Feed": { ... } }, "feedDatas": { "My Feed": { "events": [ ... ], "has_more": true, "total": 42 } } }
POST /api/update-feeds Auth required

Replace saved feed configurations and return refreshed feed data.

Request Body
{ "savedFeeds": { "My Feed": { ...feed_config } }, "per_feed_limit": 20 }
ParameterTypeDescription
savedFeeds*objectThe full set of feed configs to save
per_feed_limitintMax events returned per feed. Default: 20 (clamped 1–200)
Query ParamTypeDescription
unprocessedEventsIncludedboolInclude events without a generated summary. Default: false
Response 200
{ "success": true, "feedDatas": { "My Feed": { ... } } }
Sending an explicit empty object ({"savedFeeds": {}}) clears all feeds. Sending no body at all returns 500 and leaves your saved feeds untouched.
GET /api/refresh-feeds Auth required

Refresh feed data using current saved configurations (no changes to feed settings). Pass feed_name to refresh a single feed, with per_feed_limit/offset for paging through its events.

Query ParamTypeDescription
feed_namestringRefresh only this feed. Omit to refresh all.
per_feed_limitintMax events per feed. Default: 20
offsetintSkip events within the feed for paging. Default: 0
unprocessedEventsIncludedboolInclude events without a generated summary. Default: false
Response 200 — all feeds
{ "success": true, "feedDatas": { "My Feed": { "events": [ ... ], "has_more": true, "total": 42 } } }
Response 200 — single feed (feed_name set)
{ "success": true, "feedDatas": { "BigEvents": { "events": [ ... ], "has_more": true, "total": 40 } }, "events": [ ...selected feed's events, mirrored at top level ], "has_more": true, "total": 40 }
404{"success": false, "error": "Feed not found"} when feed_name isn't one of your saved feeds

Agent & Chat

The agent is a tool-using AI assistant over your Ergen data. Chat is a streaming (SSE) endpoint and is token-metered — usage is charged per turn against your tier's window rather than a flat call quota. Conversations are persisted per user.

POST /api/agent/chat Auth · SSE

Send a message and stream the assistant's reply as Server-Sent Events. Pass conv_id to continue an existing conversation, or omit it to start a new one (the first event returns the new id).

Request Body
{ "message": { "content": "Who are Amazon's top suppliers?", "mentions": [] }, "conv_id": "<optional existing id>", "model_id": "gemini-3-flash-preview" }
message is an object ({content, mentions}), not a string. mentions is an optional array. Alternatively send a legacy messages array of {role, content, mentions} when message is omitted.
Response — text/event-stream
// each line: data: {json}\n\n data: { "type": "conv_id", "conv_id": "...", "is_new": true } data: { "type": "thinking_segment", "content": "..." } data: { "type": "tool_start", "tool": "search_events", "args": { ... } } data: { "type": "tool_done", "tool": "search_events", "result": "..." } data: { "type": "content", "content": "Amaz" } data: { "type": "artifact", "title": "...", "url": "...", "page_type": "..." }
400{"error": "No messages provided"} when neither message nor a non-empty messages array is sent
429Token window exhausted — JSON body {"error","window","used","cap","resets_at"} with a Retry-After header (admins are exempt)
GET /api/agent/models Auth required

List the models selectable for agent chat and the default.

Response 200
{ "models": [{ "id": "gemini-3-flash-preview", "label": "Gemini 3 Flash", "provider": "gemini" }], "default": "gemini-3-flash-preview" }
GET /api/agent/conversations Auth required

List your conversations (pinned first, then most-recently updated).

Response 200
{ "conversations": [{ "id": "...", "title": "Untitled", "created_at": "...", "updated_at": "...", "message_count": 4, "pinned": false }] }
POST /api/agent/conversations Auth required

Create or update (upsert) a conversation. Omit id to create a new one.

Request Body
{ "id": "<optional>", "messages": [ ... ], "title": "optional" }
Response 200
{ "id": "...", "title": "..." }
400{"error": "No messages"} when messages is empty/absent
GET /api/agent/conversations/{convId} Auth required

Get a single conversation including its full message list.

404Conversation not found
POST /api/agent/conversations/{convId}/title Auth required

Auto-generate a short title from the first exchange.

Request Body
{ "first_message": "...", "first_response": "..." }
Response 200
{ "title": "Amazon suppliers" }
404{"error": "Not found"} — conversation doesn't exist
POST /api/agent/conversations/{convId}/rename Auth required

Rename a conversation.

Request Body
{ "title": "New title" }
Response 200
{ "title": "New title" }
400{"error": "No title"} when title is blank
404{"error": "Not found"} — conversation doesn't exist
POST /api/agent/conversations/{convId}/pin Auth required

Toggle the pinned state. Returns the new value.

Response 200
{ "pinned": true }
404{"error": "Not found"} — conversation doesn't exist
DELETE /api/agent/conversations/{convId} Auth required

Delete a conversation.

Response 200
{ "success": true }

Routines

Routines are scheduled agent prompts — they run on a trigger (once, hourly, daily, weekdays, weekly, or a custom interval) and record each execution as a run. They can also be triggered on demand, synchronously or as an SSE stream.

Routine Object
{ "id": "...", "user_id": "...", "name": "Morning brief", "prompt": "...", "model_id": "gemini-3-flash-preview", "trigger": { "type": "daily", "trigger_time": "08:00", "trigger_weekday": null, "trigger_minute": null, "trigger_at": null, "interval_minutes": null }, "enabled": true, "last_run_at": null, "next_run_at": "...", "created_at": "...", "updated_at": "..." }
GET /api/routines Auth required

List your routines.

Response 200
{ "routines": [ ...routine objects ] }
POST /api/routines Auth required

Create a routine. Returns the created object.

Request Body
{ "name": "Morning brief", "prompt": "Summarise overnight events on my feeds", "model_id": "gemini-3-flash-preview", "trigger": { "type": "daily", "trigger_time": "08:00" } }
trigger.typeRequired fields
oncetrigger_at (ISO datetime)
hourlytrigger_minute
daily / weekdaystrigger_time
weeklytrigger_time, trigger_weekday
custominterval_minutes
Response 201
{ "routine": { ...routine object } }
400Missing name/prompt or invalid trigger
GET /api/routines/{routineId} Auth required

Get one routine plus its recent runs.

Response 200
{ "routine": { ... }, "runs": [ ... ] }
404Routine not found
PUT /api/routines/{routineId} Auth required

Update a routine. Send only the fields you want to change.

Request Body
{ "name": "...", "prompt": "...", "trigger": { ... } }
Response 200
{ "routine": { ... } }
400Invalid model_id or trigger type
404{"error": "Not found"} — routine doesn't exist
DELETE /api/routines/{routineId} Auth required

Delete a routine.

Response 200
{ "success": true }
404{"error": "Not found"} — routine doesn't exist
POST /api/routines/{routineId}/toggle Auth required

Enable/disable a routine (flips enabled). Returns the updated object.

Response 200
{ "routine": { ..., "enabled": false } }
404{"error": "Not found"} — routine doesn't exist
POST /api/routines/{routineId}/run-now Auth required

Run a routine immediately and wait for the result (synchronous).

Response 200
{ "run": { "id": "...", "status": "success", ... } }
POST /api/routines/{routineId}/run-now/stream Auth · SSE

Run a routine immediately and stream progress as Server-Sent Events.

Response — text/event-stream
data: { "type": "run_started", "run": { "id": "...", "routine_id": "...", "started_at": "...", "status": "running" } } data: { "type": "thinking_segment", "content": "..." } data: { "type": "content", "content": "..." } data: { "type": "run_done", "run": { "id": "...", "status": "success", "finished_at": "...", "result_text": "..." }, "produced_output": true } data: [DONE]
404{"error": "Not found"} — routine doesn't exist (returned before the stream begins)
GET /api/routines/{routineId}/runs Auth required

Get a routine's run history, most-recent first.

Query ParamTypeDescription
limitintMax runs. Default: 50 (clamped to 200)
Response 200
{ "runs": [ ... ] }
404{"error": "Not found"} — routine doesn't exist

Saved Items

All saved item endpoints are per-user. Items are stored as key references (no data snapshots) and enriched with live data on retrieval. No pagination — all saved items are returned.

Events
GET /api/saved-events Auth required

Get saved events with enriched data. If an event no longer exists, data.missing will be true.

Query ParamTypeDescription
unprocessedEventsIncludedboolInclude placeholder/unprocessed saved events. Default: false
Response 200
{ "success": true, "events": { "<event_id>": { "savedDate": "...", "notes": "", "data": { "eventTitle": "...", "eventCategory": "...", "eventType": "...", "sentiment": 25, "attention": 70, "lastUpdateTime": 1713600000, "articleCount": 42 } } } }
POST /api/saved-events/{event_id} Auth required

Save an event.

Body
{ "notes": "optional" }
Response 200
{ "success": true, "message": "Event saved successfully" }
DELETE /api/saved-events/{event_id} Auth required

Remove a saved event.

Response 200
{ "success": true, "message": "Event removed successfully" }
404Event not found in saved items
Entities
GET /api/saved-entities Auth required

Get saved entities with enriched metadata, types, and activity metrics. If an entity no longer exists, data.missing will be true.

Response 200
{ "success": true, "entities": { "<entity_ref>": { "savedDate": "...", "notes": "", "data": { "displayName": "...", "entityType": "per", "types": { ... }, "activeEvents": 15, "lastUpdateTime": 1713600000 } } } }
POST /api/saved-entities/{entity_name} Auth required

Save an entity.

Body
{ "notes": "optional" }
Response 200
{ "success": true, "message": "Entity saved successfully" }
DELETE /api/saved-entities/{entity_name} Auth required

Remove a saved entity.

Response 200
{ "success": true, "message": "Entity removed successfully" }
404Entity not found in saved items
Relationships
GET /api/saved-relationships Auth required

Get saved relationships with enriched gate score, processing status, and action count. Keys are canonically sorted: entityA:::entityB (alphabetical). Note the per-item fields use snake_case.

Response 200
{ "success": true, "relationships": { "<rel_key>": { "saved_date": "...", "notes": "", "entity_a": "...", "entity_b": "...", "data": { "entity_a_display_name": "...", "entity_b_display_name": "...", "entity_a_type": "per", "entity_b_type": "cnt", "pair_type": "...", "gate_score": 0.0, "stat_n_co_events": null, "stat_evidence_score": null, "rel_label": null, "processed": false, "last_processed_LLM": null, "mutual_cluster_count": 12, "action_count": 35 } } } }
POST /api/saved-relationships Auth required

Save a relationship.

Body
{ "entityA": "...", "entityB": "...", "notes": "" }
Response 200
{ "success": true, "message": "Relationship saved successfully" }
400Missing entityA or entityB
DELETE /api/saved-relationships/{entityA}/{entityB} Auth required

Remove a saved relationship. Entities must be in canonical (alphabetical) order — use the key from GET /api/saved-relationships.

Response 200
{ "success": true, "message": "Relationship removed successfully" }
404Not found (or wrong entity order)
Paths
GET /api/saved-paths Auth required

Get saved paths with enriched display names and computed metrics. Per-item fields use snake_case.

Response 200
{ "success": true, "paths": { "<path_id>": { "saved_date": "...", "notes": "", "data": { "entities": [ ... ], "entities_display": [ ... ], "length": 2, "num_processed": 1, "total_steps": 2, "avg_gate_score": 0.0, "last_rel_update": null } } } }
POST /api/saved-paths Auth required

Save a path. Minimum 2 entities required.

Body
{ "entities": ["ref1", "ref2"], "notes": "" }
Response 200
{ "success": true, "message": "Path saved successfully", "pathId": "ref1:::ref2" }
400Fewer than 2 entities
DELETE /api/saved-paths/{path_id} Auth required

Remove a saved path. The path_id is entity1:::entity2:::entity3.

Response 200
{ "success": true, "message": "Path removed successfully" }
404Path not in saved items

User Settings

GET /api/user/settings Auth required

Get user's general settings (theme, layout preferences, etc.). settings is a free-form preferences object — empty {} when none saved.

Response 200
{ "success": true, "settings": { ... } }
PUT /api/user/settings Auth required

Update settings. Merges with existing — send only the fields you want to change.

Request Body
{ "theme": "dark", "relmapLayout": "force" }
Response 200
{ "success": true, "settings": { ...merged settings } }
GET /api/user/actions-log Auth required

Get your action log with pagination. Returns most-recent-first.

Query ParamTypeDescription
limitintMax entries. Default: 100
offsetintSkip entries. Default: 0
Response 200
{ "success": true, "total": 500, "actions": [{ "timestamp": 1713600000.1, "endpoint": "get_event", "method": "GET", "request": { ... }, "response": { ... }, "status_code": 200 }] }

Messages & Feedback

In-app messaging between users and admins.

User Endpoints
GET /api/messages/my Auth required

Get your full message history (chronological).

Response 200
{ "success": true, "messages": [{ "id": 1, "sender": "user", "content": "...", "page_url": "{\"path\":\"/feeds\"}", "attachment": null, "read": 0, "created_at": "2026-06-27 18:00:00" }] }
POST /api/messages/send Auth required

Send a feedback message. At least one of content or attachment required.

Request Body
{ "content": "Message text", "page_url": "{\"path\": \"/feeds\"}", "attachment": "<base64_or_url>" }
Response 200
{ "success": true }
400Empty message (no content and no attachment)
GET /api/messages/unread-count Auth required

Get count of unread admin replies.

Response 200
{ "success": true, "count": 3 }
POST /api/messages/mark-read Auth required

Mark all admin replies as read.

Response 200
{ "success": true }
Admin Endpoints
GET /api/messages/conversations Admin only

List all user conversations. unread_count reflects user messages the admin hasn't read.

Response 200
{ "success": true, "conversations": [{ "user_id": "...", "username": "alice", "last_message": "...", "last_message_at": "2026-06-27 18:00:00", "last_sender": "user", "unread_count": 2, "total_messages": 7 }] }
GET /api/messages/conversation/{user_id} Admin only

Get full message thread for a user. Auto-marks user messages as read.

Response 200
{ "success": true, "messages": [ ...same shape as /api/messages/my ] }
POST /api/messages/reply/{user_id} Admin only

Reply to a user.

Body
{ "content": "reply text" }
Response 200
{ "success": true }
400Empty message
GET /api/messages/stats Admin only

Get messaging analytics: totals, top pages (10), top users (10), daily volume (30 days).

Response 200
{ "success": true, "stats": { "total_messages": 500, "user_messages": 350, "admin_messages": 150, "unique_users": 25, "unreplied_conversations": 3, "top_pages": [{ "page_url": "...", "count": 12 }], "daily_volume": [{ "day": "2026-06-27", "count": 5 }], "top_users": [{ "username": "alice", "user_id": "...", "count": 9 }] } }

Analytics

All analytics endpoints require admin role. Data is based on user action logs from the last 30 days. Results are cached for 60 seconds.

GET /api/analytics/overview Admin only

Dashboard summary: total users, total actions, actions today/this week, and 5 most recently active users.

Response 200
{ "success": true, "overview": { "total_users": 25, "total_actions": 5000, "actions_today": 120, "actions_week": 800, "recent_users": [{ "user_id": "...", "username": "alice", "last_active": 1713600000, "actions_today": 5 }] } }
GET /api/analytics/users Admin only

Per-user breakdown: total actions, actions today, last active timestamp, and top 3 endpoints per user.

Response 200
{ "success": true, "users": [{ "user_id": "...", "username": "alice", "total_actions": 42, "actions_today": 5, "last_active": 1713600000, "top_endpoints": [{ "name": "...", "count": 12 }] }] }
GET /api/analytics/user/{user_id} Admin only

Single user drilldown: daily counts (30 days), endpoint breakdown by category, and last 50 actions.

Response 200
{ "success": true, "user": { "user_id": "...", "username": "alice", "total_actions": 42, "daily_counts": [{ "day": "2026-06-27", "count": 5 }], "endpoints": [{ "name": "...", "count": 12, "category": "Objects" }], "categories": [{ "name": "Objects", "count": 20 }], "recent_actions": [{ "endpoint": "...", "method": "GET", "status_code": 200, "time": "...", "params": { ... } }] } }
404User not found in action logs
GET /api/analytics/features Admin only

Feature/endpoint usage across all users. Top 20 endpoints returned, grouped by category.

Response 200
{ "success": true, "features": { "endpoints": [{ "name": "...", "count": 500, "category": "Objects" }], "categories": [{ "name": "Objects", "count": 800 }] } }
GET /api/analytics/objects Admin only

Most-viewed objects (entities, events, relationships) across all users. Top 30 per category. Pass ?user_id=... to filter to a specific user.

Response 200
{ "success": true, "objects": { "entities": [{ "ref": "...", "count": 8 }], "events": [{ "ref": "...", "count": 5 }], "relationships": [{ "ref": "...", "count": 3 }], "totals": { "unique_entities": 200, "unique_events": 150, "unique_relationships": 80, "entity_lookups": 5000, "event_lookups": 2000, "relationship_lookups": 800 } } }

User Management

Admin-only endpoints for reviewing accounts and approving access requests by setting a user's tier.

GET /api/admin/users Admin only

List all users. Pass ?pending=true to show only accounts awaiting approval.

Query ParamTypeDescription
pendingbool1/true/yes shows only pending accounts
Response 200
{ "success": true, "count": 37, "users": [ ... ] }
POST /api/admin/users/{userId}/tier Admin only

Set a user's tier (and optionally role). Promoting a pending account out of the pending tier approves it.

Request Body
{ "tier": 2, "role": "client" }
Response 200
{ "success": true, "user": { "user_id": "...", "email": "...", "role": "client", "tier": 2 } }
400Invalid tier/role
404User not found
GET /api/usage/{userId} Admin only

Inspect another user's tier and current usage windows (see GET /api/usage for the shape).

Response 200
{ "success": true, "user_id": "...", "tier": 2, "role": "client", "unlimited": false }

Health & Status

GET /health No auth

Health check.

Response 200
{ "status": "healthy", "service": "unified-backend" }
GET /api/data-status Auth required

Get current database status with live counts. last_update is "never" if no events exist.

Response 200
{ "last_update": "2026-04-15T10:30:00", "events_count": 15000, "entities_count": 8500 }
GET /api/stats Auth required

Aggregate database statistics.

Response 200
{ "success": true, "stats": { "active_events": 15000, "entities": 8500, "actions": 120000, "rel_index_rows": 45000, "last_event_update": 1713600000 } }
GET /api/usage Auth required

Your account's tier and current usage windows. unlimited is true for admins (no caps); for metered tiers, session and weekly report the cap, amount used, and reset time.

Response 200
{ "success": true, "tier": 1, "role": "client", "unlimited": false, "session": { "cap": 100000, "used": 12000, "resets_at": "..." }, "weekly": { "cap": 500000, "used": 90000, "resets_at": "..." } }