# Oculo > Self-hosted log and error platform. This file is everything an LLM agent needs to use it: auth, the MCP server, > the query language and every endpoint with its request body. Base URL: https://oculo-api.funonymous.in > Machine-readable: https://oculo-api.funonymous.in/openapi.json (OpenAPI 3.1, every endpoint with parameters and body schemas). > Full human docs (Markdown): https://oculo-api.funonymous.in/docs.md ## Two ways to use it 1. **HTTP + curl** (any agent with a shell, e.g. Claude Code): send `Authorization: Bearer ` to the endpoints below. Example: `curl -s -H "Authorization: Bearer $OCULO_KEY" "https://oculo-api.funonymous.in/api/v1/summary?format=md"` 2. **MCP tools** (Claude Code, Claude Desktop, Cursor…): see "MCP server". Same data, typed tools, no URLs to build. ## Keys and permissions Keys are sent as `Authorization: Bearer ` (also `X-Oculo-Key: ` or `?key=`). Every endpoint below is tagged with who may call it: - [public] no key - [ingest] ingest key (also read/write key, member or admin) - [viewer] any key or account with access to the project (read key is enough) - [member] read/write key, member/owner account, or admin key - [alerts] member/owner account or admin key (never project keys: alerts send outbound requests) - [owner] owner account or admin key - [admin] admin key or instance-admin account - [signed-in] signed-in account (session cookie) Key types: - `oc_ingest_…` (per project): send logs and heartbeat pings only. - `oc_read_…` (per project): read everything in its project. Created with can_write:true it is a **read/write key** = member rights (resolve/ignore issues, saved searches, heartbeats, source maps) but it can **not** manage alerts, keys, members or settings. - `oc_admin_…`: everything in every project, including creating projects. - Accounts (dashboard sign-in) have a role per project: viewer < member < owner; instance admins can do everything. Project selection: per-project keys need nothing extra. Admin keys and accounts with several projects add `?project=` to any endpoint that works inside a project (reads and writes). Paths with /projects/{id} take the slug or the numeric id. Errors are JSON: `{"error": "...", "hint": "..."}` (hint is optional) with a normal HTTP status (400 bad input, 401 bad key, 403 not allowed, 404, 409 exists, 413 too big, 429/503 slow down). ## Start here (reading) 1. `GET /api/v1/summary?format=md` — health: error_rate (percent), top/new/regressed issues, top error messages, releases. 2. `GET /api/v1/issues?sort=freq&format=md` — grouped errors, then `GET /api/v1/issues/{id}?format=md` for the stack trace. 3. `GET /api/v1/logs/search?q=…&since=1h&format=md&limit=50` — raw logs; `GET /api/v1/logs/{id}/context?format=md` around one. 4. `GET /api/v1/releases/compare?format=md` — did the last deploy break something? 5. `GET /api/v1/patterns?format=md`, `GET /api/v1/metrics?fn=p95&field=@duration_ms&group_by=@route` (find fields with `GET /api/v1/fields`). Output control on read endpoints: `format=json|md|text` (md = compact tables, best for LLM context; text = one line per log), `fields=timestamp,level,message,attributes.route` to trim rows, `limit` (hard caps) and `next_cursor` → `cursor=` for paging. ## Setting things up (writing) Typical order: create a project (admin) → send logs with its ingest key → create keys for agents → alerts → heartbeats → invite people. Request bodies are JSON (`Content-Type: application/json`). Every write endpoint and its body is listed under "Endpoints" with an example. Alert types: threshold, spike, new_issue, regression, heartbeat, digest. Channels: webhook, slack, discord, email, telegram, pagerduty. - threshold fires when the number of matching logs in the last window_min minutes is >= threshold; spike additionally needs >= 3x the average of the previous 12 windows. Both use query + threshold + window_min; new_issue/regression need nothing else; heartbeat uses config.heartbeat (slug, empty = all); digest uses config.hour (UTC). - webhook/slack/discord: target = URL · email: target = addresses (server needs SMTP_*) · telegram: target = chat id + config.bot_token · pagerduty: config.routing_key. - Webhook payload: {alert:{id,name,type}, project, subject, message, details, url, fired_at}; test sends have details.test = true. Durations (heartbeat period/grace): 30m, 6h, 1d, 1w or seconds. ## MCP server Package `@gouravsrf/oculo-mcp` (Node 18+). Add it to Claude Code once, from a terminal: claude mcp add oculo -- npx -y @gouravsrf/oculo-mcp --url https://oculo-api.funonymous.in --key Other clients (Claude Desktop, Cursor…), in their MCP JSON config: "oculo": {"command": "npx", "args": ["-y", "@gouravsrf/oculo-mcp", "--url", "https://oculo-api.funonymous.in", "--key", ""]} The tools appear after the client restarts (in Claude Code: start a new session, check with /mcp). Options: --project (for keys that see several projects), --allow-write (exposes resolve_issue; needs a read/write key). Use a read key. Tools (all read-only unless noted; every tool except list_projects also takes an optional `project` slug; times like since are strings such as "1h" or "7d"; results are Markdown text): - project_summary(hours?): project health; call it first - list_projects(): projects this key can see - search_logs(query, since?, until?, limit?, cursor?, order?, fields?): search with the query language - get_log_context(id, n?, scope?): a log with its neighbours and trace - list_issues(status?, sort?, query?, release?, since?, limit?): grouped errors - get_issue(id): stack trace, breadcrumbs, timeline, releases, users - compare_releases(a?, b?): what a deploy changed - list_patterns(query?, since?, sort?, limit?): log templates with counts - query_metrics(fn, field?, group_by?, query?, since?, until?, buckets?): count/avg/percentiles over time - list_fields(): attribute names and types - explain_issue(id, refresh?): AI cause and fix, if the server enables it - resolve_issue(id): only with --allow-write and a read/write key MCP covers reading and triage. For setup (projects, keys, alerts, heartbeats, invites) use the HTTP endpoints. ## Query language (`q=`, MCP `query`) Terms are ANDed. Case- and accent-insensitive free text. - Bare words: word-prefix match over message, exception and attributes (`pay` matches "payment"). `"exact phrase"`. - A leading `-` negates any term: `-healthcheck`, `-env:dev`. - `field:value`; `,` = OR (`env:prod,staging`); `*` wildcard (`release:1.4*`); comparisons `>= > <= < !=`. - Fields: `level` (trace|debug|info|warn|error|fatal, e.g. `level:>=warn`), `env`, `service`, `release`, `host`, `logger`, `trace`, `span`, `user`, `type` (exception type), `issue`, `pattern`, `id`, `message`. - `@path.to.attr:value` for attributes (nested or dotted keys): `@status:>=500`, `@route:/checkout*`, `@user.plan:pro`. Unknown field names are attributes. - `has:exception|user|trace|release|@attr`; `since:2h until:30m` (s m h d w, ISO or epoch ms; also as since=/until= params). Examples: payment failed level:>=warn env:prod,staging -healthcheck @status:>=500 @route:/checkout* release:1.4* since:6h "connection refused" has:exception since:2h until:30m ## Event format (ingest) {"timestamp":"2026-11-14T16:20:01.123Z","level":"error","message":"Payment failed", "logger":"billing","service":"api","environment":"prod","release":"1.4.2","host":"web-3", "trace_id":"abc","span_id":"def","user":{"id":"u_42"}, "attributes":{"orderId":8812,"route":"/checkout"}, "exception":{"type":"CardError","value":"card_declined","stacktrace":"..."}, "fingerprint":["optional","custom","grouping"]} Only message is required. Unknown top-level fields move into attributes. Event field → query field: environment → env, exception.type → type, user.id → user, trace_id → trace, span_id → span, attributes.x → @x. Stack traces: plain text; frames are understood for JS/TypeScript, Java/Kotlin, Python and Go (other formats still group by type + message); errors are grouped into issues by exception type + message with numbers/ids removed + top in-app frames. Settings keys for PATCH /projects/{id}: scrubEmails (bool), extraKeys (strings to scrub), sampling ({level: 0..1}), dropMessages (case-sensitive substrings; applies to new logs only). SDKs: npm `@gouravsrf/oculo`, Python `oculo`, or any OpenTelemetry exporter (POST /v1/logs). ## Endpoints Tags: [who may call it] — see "Keys and permissions". Read endpoints also take format=, fields= and (admin/multi-project) project=. ### Send logs - POST /api/v1/ingest [ingest] Send events: JSON array, single object, {"events":[…]} or NDJSON; gzip/deflate/br OK; ≤5000 events, ≤10 MB. Reply {accepted, dropped} (with a queue: {accepted, dropped:0, queued:true}). body: [{"level":"error","message":"Payment failed","service":"api","attributes":{"orderId":8812}}] - POST /v1/logs [ingest] OpenTelemetry OTLP/HTTP logs, protobuf or JSON (alias: /api/v1/otlp/v1/logs) - POST /api/v1/otlp/v1/logs [ingest] Same as /v1/logs - POST /api/v1/heartbeats/{slug}/ping?period&grace [ingest] Cron check-in. ?period=1d (and ?grace=5m) creates the heartbeat on its first ping. GET also works. ### Read - GET /api/v1/summary?hours [viewer] Project health: totals, by level, error_rate (a percent, 0-100) with change vs the previous window, top/new/regressed issues, top error messages, releases. Best first call. params: hours: window, default 24 - GET /api/v1/projects/{project}/summary?hours [viewer] Same as /api/v1/summary for a given project - GET /api/v1/logs/search?q&since&until&limit&cursor&order [viewer] Search logs, newest first. Reply {data, next_cursor, highlights (matched words), took_ms}. params: q: Query language, see "Query language"; since: 30m, 2h, 7d, ISO time or epoch ms; until: same formats as since; limit: default 100, max 1000; cursor: next_cursor from the previous page; order: desc|asc - GET /api/v1/logs/{id} [viewer] One log - GET /api/v1/logs/{id}/context?n&scope [viewer] One log, n logs before and after it, and its whole trace params: n: default 10, max 200; scope: service|host — only neighbours from the same service/host - GET /api/v1/logs/histogram?q&since&until&buckets [viewer] Counts per time bucket by level, plus deploy markers params: q: Query language, see "Query language"; since: 30m, 2h, 7d, ISO time or epoch ms; until: same formats as since; buckets: 5-500, default 60 - GET /api/v1/logs/facets?q&since&until&facets&sample [viewer] Top 15 values per field over the most recent matching logs params: q: Query language, see "Query language"; since: 30m, 2h, 7d, ISO time or epoch ms; until: same formats as since; facets: comma list of level,environment,service,release,logger,host,user,exception; sample: logs scanned, default 100000 - GET /api/v1/logs/export?q&since&until&limit&as [viewer] Streamed export params: q: Query language, see "Query language"; since: 30m, 2h, 7d, ISO time or epoch ms; until: same formats as since; limit: default 100000, max 1000000; as: ndjson|csv - GET /api/v1/traces/{trace} [viewer] Every log in a trace, in order - GET /api/v1/issues?status&sort&q&release&since&limit&cursor [viewer] Errors grouped into issues params: status: unresolved (default), resolved, ignored, all, or a comma list; sort: last|freq|new|users; q: text in title/culprit; since: last seen within 30m/6h/7d; limit: default 25, max 200; cursor: next_cursor from the previous page - GET /api/v1/issues/{id} [viewer] Issue detail. Reply keys: title, culprit, status, count, user_count, first_seen, last_seen, latest_event (a full log; the stack trace is latest_event.exception.stacktrace), breadcrumbs, timeline_48h, timeline_30d, releases, users_sample, tags, recent_events - GET /api/v1/issues/{id}/events?limit&cursor [viewer] Events of one issue, newest first params: limit: default 50, max 500; cursor: next_cursor from the previous page - GET /api/v1/releases [viewer] Releases with log and error counts - GET /api/v1/releases/compare?a&b [viewer] What changed between releases a and b: new, increased, decreased and gone issues (defaults: the two latest) - GET /api/v1/rollups?days&group [viewer] Total and error counts per day (or hour) from long-lived rollups params: days: default 30, max 400; group: day|hour - GET /api/v1/patterns?q&since&until&match&level&service&sort&limit [viewer] Log templates (variable parts as <*>) with counts and share; filter logs with pattern: params: q: Query language, see "Query language"; since: 30m, 2h, 7d, ISO time or epoch ms; until: same formats as since; match: substring of the template; level: minimum level, e.g. warn; sort: count|new|rare; limit: default 50, max 500 - GET /api/v1/metrics?fn&field&group_by&q&since&until&interval&buckets [viewer] Metric over time from logs. Reply {buckets[] (bucket start times), series[{group, values[] (one per bucket, null = no data), total (over the whole window), count}], interval_ms, rows_scanned, truncated}. Top 8 groups, the rest as "other". params: fn: count|avg|sum|min|max|p50|p90|p95|p99 — default count; field: numeric attribute for non-count fns, e.g. @duration_ms; group_by: service, environment, release, host, logger, level or @attr; q: Query language, see "Query language"; since: default 24h; until: same formats as since; interval: 5m, 1h, 1d (default: about 60 buckets); buckets: 5-200, default 60 - GET /api/v1/fields [viewer] Attribute keys seen in recent logs with their type (number/string) - GET /api/v1/tail?q&ticket [viewer] Live tail, Server-Sent Events. With a key: Authorization header. Browsers: ?ticket= from POST /api/v1/tail/ticket params: q: Query language, see "Query language" - POST /api/v1/tail/ticket [viewer] One-time 60 s ticket for /api/v1/tail?ticket=… (remembers key and project) - GET /api/v1/heartbeats [viewer] Heartbeats with status (ok, late, waiting), next_due and ping_url - GET /api/v1/sourcemaps?release [viewer] Uploaded source maps - GET /api/v1/saved-searches [viewer] Saved searches - GET /api/v1/explain/status [viewer] {enabled}: whether AI explanations are configured on this server ### Triage - POST /api/v1/issues/{id}/resolve [member] Mark resolved (reopens automatically as "regressed" if it happens again) - POST /api/v1/issues/{id}/ignore [member] Mark ignored - POST /api/v1/issues/{id}/unresolve [member] Reopen - PATCH /api/v1/issues/{id} [member] Change status and/or assignee body: {"status":"resolved","assignee":"alice"} - POST /api/v1/issues/{id}/explain?refresh [viewer] AI likely cause and fix (501 unless the server sets ANTHROPIC_API_KEY; issue data is sent to Anthropic). Cached; ?refresh=1 regenerates - POST /api/v1/saved-searches [member] Save a search body: {"name":"API errors","query":"level:>=error service:api"} - DELETE /api/v1/saved-searches/{id} [member] Delete a saved search ### Set up - POST /api/v1/projects [admin] Create a project. Reply {project, ingest_key, read_key} — keys are shown once. body: {"name":"Shop API"} - GET /api/v1/projects [viewer] Projects you can see, with your role and 24h stats - GET /api/v1/projects/{id} [viewer] One project params: id: project slug or numeric id - PATCH /api/v1/projects/{id} [owner] Rename, retention, settings (merged into existing settings) params: id: project slug or numeric id body: {"retention_days":30,"settings":{"sampling":{"debug":0.1},"dropMessages":["healthcheck"]}} - DELETE /api/v1/projects/{id} [owner] Delete a project and all its data params: id: project slug or numeric id - GET /api/v1/projects/{id}/keys [owner] API keys (prefixes only) params: id: project slug or numeric id - POST /api/v1/projects/{id}/keys [owner] Create a key. kind "ingest" (send only) or "read"; can_write:true makes a read/write key (oc_read_… that can also resolve issues, save searches, manage heartbeats, upload source maps). Reply {id, kind, key} — shown once. params: id: project slug or numeric id body: {"kind":"read","name":"agent","can_write":false} - DELETE /api/v1/keys/{id} [owner] Revoke a key - GET /api/v1/alerts [alerts] Alert rules (secrets masked) - POST /api/v1/alerts [alerts] Create an alert rule body: {"name":"API errors","type":"threshold","query":"level:>=error service:api","threshold":10,"window_min":5,"channel":"slack","target":"https://hooks.slack.com/services/…","cooldown_min":30} - PATCH /api/v1/alerts/{id} [alerts] Change any alert field (same body as create, all optional) - DELETE /api/v1/alerts/{id} [alerts] Delete an alert - POST /api/v1/alerts/{id}/test [alerts] Send a test notification now. Reply {ok, error?} - GET /api/v1/alerts/{id}/events [alerts] Last 50 deliveries with ok/error - POST /api/v1/heartbeats [member] Create a heartbeat (then add a "heartbeat" alert to be notified) body: {"slug":"nightly-backup","period":"1d","grace":"15m"} - PATCH /api/v1/heartbeats/{slug} [member] Change name, period or grace - DELETE /api/v1/heartbeats/{slug} [member] Delete a heartbeat - POST /api/v1/sourcemaps?release [member] Upload a source map for one bundle of a release body: {"file":"app.3f9a1c.js","map":{"version":3,"sources":["…"],"mappings":"…"}} - DELETE /api/v1/sourcemaps/{id} [member] Delete a source map ### People and accounts - GET /api/v1/me [viewer] Who am I (key kind or user, role, project) - GET /api/v1/projects/{id}/members [viewer] Members and roles params: id: project slug or numeric id - PATCH /api/v1/projects/{id}/members/{userId} [owner] Change a member's role params: id: project slug or numeric id body: {"role":"member"} - DELETE /api/v1/projects/{id}/members/{userId} [owner] Remove a member (members may remove themselves) params: id: project slug or numeric id - POST /api/v1/projects/{id}/invites [owner] Create an invite link (7 days, single use). Reply {url}; send it to the person params: id: project slug or numeric id body: {"email":"friend@example.com","role":"member"} - GET /api/v1/projects/{id}/invites [owner] Pending invites params: id: project slug or numeric id - DELETE /api/v1/projects/{id}/invites/{email} [owner] Cancel an invite params: id: project slug or numeric id - GET /api/v1/invites/{token} [public] Invite details - POST /api/v1/invites/{token}/accept [signed-in] Join the project as the invited role - GET /api/v1/auth/config [public] {github, has_users, signup_open, ingest_url} - POST /api/v1/auth/signup [public] Create an account (browser only, same-origin). The first account becomes instance admin; later ones need an invite - POST /api/v1/auth/login [public] Sign in (browser only, sets a session cookie) - POST /api/v1/auth/logout [signed-in] Sign out - GET /api/v1/auth/github/start [public] Start "Continue with GitHub" (when configured) - GET /api/v1/auth/github/callback [public] GitHub OAuth callback ### Meta - GET /healthz [public] {ok, storage, queue, outbox_rows?, queue_backlog?} - GET /llms.txt [public] This guide - GET /openapi.json [public] OpenAPI 3.1 description of every endpoint - GET /docs [public] Full documentation (HTML) - GET /docs.md [public] Full documentation (Markdown)