# Rootr > Rootr is an AX-first (AI-experience-first) knowledge collaboration platform. > Markdown documents are automatically ontologized into a SurrealDB knowledge > graph, and a typed log datastore (LOG stores) captures structured events > whose relation fields auto-build a data-lineage graph that is promoted into > the same knowledge graph. An AI assistant answers root-cause-analysis (RCA) > questions over that graph using GraphRAG (hybrid vector + graph retrieval, > the `ask` query/endpoint). This document is for AI assistants and coding agents that want to integrate with the Rootr API. It is plain Markdown, safe to paste into a system prompt or fetch at runtime. ## Base URLs - REST: `https://rootr.io/api/v1` - GraphQL: `https://rootr.io/api/graphql` ## Authentication Two supported credential types, checked in this order on every request: 1. **API key** — header `x-api-key: rootr_...`. Workspace-scoped, created by a workspace admin from **Settings → API Keys** (`POST /v1/workspaces/:ws/api-keys` with `{ "name": "...", "scopes": [...] }`). The plaintext key is shown exactly once at creation time. Available scopes: `docs:read`, `docs:write`, `graph:read`, `ask`, `workspaces:create` (account-scoped keys, for `POST /v1/scaffold/workspace`), and `webhooks:manage` (webhook CRUD — granted by default on new keys so agent integrations can self-register their hooks). Each endpoint below is annotated with the scope it requires — an API key missing that scope gets a 403. 2. **JWT user session** — header `Authorization: Bearer `, obtained via the normal login flow. JWT calls are governed by the calling user's workspace/document ACL rather than by scopes. Either credential type works on any `docs:*`/`graph:read`/`ask`-scoped route; use an API key for unattended/agent integrations. ## Machine-readable API descriptions - `https://rootr.io/api/api-docs-json` — full OpenAPI 3 spec for the REST API (`/v1/**`). - `https://rootr.io/api/graphql/sdl` — GraphQL SDL for `https://rootr.io/api/graphql`. - `https://rootr.io/api/api-docs` — human-browsable Swagger UI, if you want to look around interactively before scripting anything. ## Official MCP server & CLI (`rootr-cli`) The fastest way for an agent to use Rootr is the official MCP server — no HTTP code needed. ### Remote MCP connector (recommended) - **Endpoint**: `https://rootr.io/mcp` (Streamable HTTP) - **Auth**: OAuth 2.1 with dynamic client registration — no API key needs to be minted ahead of time. - **Setup**: in Claude or ChatGPT's "add custom connector" screen, paste the endpoint URL and approve the login prompt. No install step. - Human-readable walkthrough: `https://rootr.io/en/mcp` (Korean: `https://rootr.io/ko/mcp`). ### CLI / local install (alternative for local or headless environments) The same npm package is also a CLI you can register with a locally-running agent — useful when a remote OAuth redirect isn't available (headless boxes, CI, sandboxed environments). - **One-click setup**: in the app, **Settings → Integrations** mints a key and copies a ready-to-paste registration command. - Claude Code: `claude mcp add rootr --env ROOTR_API_KEY=rootr_... --env ROOTR_WORKSPACE= -- npx -y rootr-cli mcp` - Codex CLI (persists to `~/.codex/config.toml`): `codex mcp add rootr --env ROOTR_API_KEY=rootr_... --env ROOTR_WORKSPACE= -- npx -y rootr-cli mcp` - Claude Desktop (`claude_desktop_config.json`): `{"mcpServers":{"rootr":{"command":"npx","args":["-y","rootr-cli","mcp"],"env":{"ROOTR_API_KEY":"rootr_...","ROOTR_WORKSPACE":""}}}}` - Env vars: `ROOTR_API_KEY`, `ROOTR_WORKSPACE` (default workspace for workspace-scoped tools), `ROOTR_BASE_URL` (self-hosted only; defaults to `https://rootr.io/api/v1`). Its tools mirror this API (214; call `tools/list` for the authoritative set). The most used ones: - documents — `rootr_list`, `rootr_read`, `rootr_append` (conflict-free, agents' default write), `rootr_edit` (anchored find/replace), `rootr_write`, `rootr_search`, `rootr_embed_node` (put a LIVE table/sheet/deck inside a document — see "Embedding" below), `rootr_document_versions`, `rootr_delete_node`, `rootr_restore_node` (undo a delete — the trash, and what Ctrl+Z does in the app; it takes the node **id**, since a trashed node is no longer on the tree) - images & files (첨부) — `rootr_attach_image_to_document` (upload + insert as `![alt](url)` in ONE call; `underHeading` places it under a section), `rootr_upload_attachment` (upload only → `{ id, url, markdown }`; `url` is the public `/attachments/{id}/raw` link, which renders in `` and on published `/p/` pages without login — never use the bare `/attachments/{id}`, it needs auth). Over the **remote connector** send the bytes as `base64Data` (`filePath` is not offered there — it would name a file on Rootr's server); with the **local CLI server** (`npx rootr-cli mcp`) pass `filePath` and the server reads the file on your machine. For larger files on the remote connector, skip base64 entirely: `rootr_create_upload_url` returns a short-lived presigned S3 URL — PUT the raw bytes there yourself, then call `rootr_complete_upload` with the returned `id` to get back `{ id, url, markdown }`. Shell: `rootr attach --doc [--heading "## X"] [--alt ]`. - workspaces & scaffolding — `rootr_workspaces`, `rootr_use_workspace` (switch the active workspace by id OR name, mid-session — see below), `rootr_scaffold_plan`, `rootr_scaffold_review`, `rootr_create_workspace`, `rootr_scaffold_apply`, `rootr_review_workspace`, `rootr_test_workspace`, `rootr_delete_workspace` (clean up an EMPTY workspace left behind by a scaffold run that failed — needs OWNER and the exact name typed again), `rootr_set_workspace_emblem` (the workspace logo in the sidebar badge and switcher — an image file, an already-uploaded image `attachmentId`, or `clear: true`; needs ADMIN). REST: `PATCH /v1/workspaces/:ws` with `{ "iconUrl": "/api/v1/attachments//raw" }` (an image attachment of that same workspace; `null` clears it). Shell: `rootr emblem | --clear`. - databases (tables / boards / kanban) — `rootr_create_database` (columns, views and first rows in ONE call — the `properties` order IS the column order, so never build a table by adding columns one at a time), `rootr_read_database`, `rootr_list_rows`, `rootr_add_row`, `rootr_update_row`, `rootr_delete_row`, `rootr_find_similar_rows` (rows of the same table closest in MEANING to a given row — vector search over the row's columns; a column marked `searchExcluded` ("검색 색인에서 제외" / exclude from search index, set with `rootr_update_column`) is never indexed, and "own rows only" tables only return your rows; only rows that stand out from the rest of the table come back, each with `similarityLevel` high|mid — unrelated rows of one table often score 0.8+ raw cosine; `state` tells you if the table is still indexing, the row has nothing to compare ("no-text": empty, or only excluded columns filled — waiting will not help) or graph sync is off), `rootr_set_row_vectors` / `rootr_query_vectors` (a `vector` column is YOUR vector store: add it with `rootr_add_column` type "vector" + `dimensions`, put your own embeddings per row, then query by cosine — rows keep their permissions; Rootr embeds nothing here and charges no AI credits) (a row's DETAIL BODY — the note under a kanban card — is the `body` argument on add/update, and comes back with `includeBody: true` on read), `rootr_add_column`, `rootr_update_column`, `rootr_delete_column`, `rootr_read_database_permissions` / `rootr_set_database_permissions` (locks: schema, own-rows-only, manager-only columns — none of which Notion has), `rootr_read_database_notifications` / `rootr_set_database_notifications` (who gets pinged when a row is added/changed/deleted, with an optional filter), `rootr_list_database_buttons` / `rootr_set_database_buttons` / `rootr_run_database_button` (the action buttons above a table — press one to add a row, fill values, have AI write a cell, or create the row in ANOTHER table already linked to the one you pressed on; see the button section below), `rootr_list_database_automations` / `rootr_set_database_automations` (the same steps with nobody pressing anything: "when 상태 becomes 투입확정, create three follow-up rows", or at a set time each morning), `rootr_import_folded_sheets` (fold a one-sheet-per-record workbook — 783 sheets, one per site — into a single table; always `dryRun` first) - access (누가 볼 수 있는지) — `rootr_read_node_access`, `rootr_set_node_visibility` (PRIVATE hides a node and its whole subtree from members' default access), `rootr_grant_node_permission` / `rootr_revoke_node_permission` (READ/WRITE/MANAGE for one person or group). Set PRIVATE + grants together to build "a folder only these three can see" — a scaffold tree can carry the same as `visibility`/`access` per node, so the locking happens as the tree is created rather than being left to the user. This is workspace-internal access; publishing to the web is `rootr_set_document_public`. - forms — `rootr_create_form`, `rootr_read_form`, `rootr_update_form`, `rootr_list_form_responses` (`latestPerRespondent: true` keeps only each signed-in submitter's newest answer), `rootr_submit_form_response`, `rootr_update_form_response` / `rootr_delete_form_response` (fix or drop one already-submitted response), `rootr_create_form_collector` (build the DATABASE — columns straight from the questions — or the SPREADSHEET that collects the answers, in one call; a hand-built table only mirrors if every column name matches a question), `rootr_form_mapping` / `rootr_set_form_mapping` (**check this right after linking a form to a hand-built table**: a question with no column is dropped with no error anywhere, and `unmatched` is the only place that says so), `rootr_create_form_share_link`, `rootr_list_form_share_links` - templates (문서 양식) — `rootr_list_templates` (check BEFORE building a document or table from scratch: the team may already have an agreed format), `rootr_apply_template`, `rootr_save_as_template`, `rootr_update_template` - LOG datastores — `rootr_create_log_store`, `rootr_update_log_fields`, `rootr_add_log_entries`, `rootr_query_log_entries`, `rootr_log_stats` - RCA — `rootr_ask` (GraphRAG answer with citations) - issues — `rootr_create_issue_tracker`, `rootr_list_issues`, `rootr_create_issue`, `rootr_get_issue`, `rootr_update_issue`, `rootr_comment_issue` - webhooks — `rootr_list_webhooks`, `rootr_create_webhook`, `rootr_delete_webhook` - custom domains (내 도메인 연결) — `rootr_list_custom_domains`, `rootr_add_custom_domain` (own domain: returns the one DNS line + steps for the detected DNS provider; `subdomain` → a free name.rootr.site, live at once), `rootr_check_custom_domain`, `rootr_rename_custom_domain` (fix the name of a not-yet-live own domain — the paid slot moves, no charge), `rootr_remove_custom_domain` — see *Custom domains* below Key types: a **workspace API key** covers everything inside one workspace; an **account key (PAT)** — created under Profile → Account keys — acts as the user across their workspaces and is required for `rootr_create_workspace` (`workspaces:create` scope) and `rootr_comment_issue` (comments carry a user identity). CLI usage: `npx rootr-cli read /path.md`, `append`, `edit`, `search`, `rm`, `ws`, `ask`, `db` (create/rows/row-add/row-body/lock/notify), `access` (공개범위·권한), `form` (responses/response-edit/response-rm) — see `npx rootr-cli --help`. ### Switching workspaces without reconfiguring Call `rootr_use_workspace` with a workspace id **or its name**; every later tool call applies to it. With an account key (PAT) you can switch to any workspace you belong to. If you hold several workspace keys, register them once (`npx rootr-cli config --add-key rootr_... --label ""`) and the tool picks — and remembers — the key that covers the target workspace. A remote (OAuth) connector is bound to the single workspace approved at consent time and cannot switch; issue another connector for another workspace. If the local server was started with the `ROOTR_WORKSPACE` env var, that value wins again on every restart: the switch holds while the server process runs, and the reply names the workspace it falls back to after a restart/reconnect — call `rootr_workspaces` again before writing if the server may have restarted. To keep the switch, change or remove `ROOTR_WORKSPACE` in the MCP client config. ## Rate limits Programmatic traffic (API key / MCP, **not** logged-in web sessions) is capped per plan. Only read/query scopes count — `docs:read`, `graph:read`, `ask`; writes and uploads are never rate-limited (storage quota bounds those instead). Reading a published public URL through MCP (`rootr_read` on `rootr.io/p/{slug}` — for another workspace's document it reads `GET /v1/public/docs/:slug` with your key) counts as **one read**, same as reading your own document; a failed attempt (missing, unpublished or password-locked) counts too. The same URL opened without an API key (a browser, a signed-in session) is never counted. An account key (PAT) passes `?workspaceId=` so the read is charged to that workspace. | Plan | Cap | | --- | --- | | Free | **180 / day**, resetting at midnight KST | | Team | 5,000 / hour | | Business | 50,000 / hour | | Platform | unlimited | Every rate-limited response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (unix seconds), `X-RateLimit-Window` (`day`|`hour`) and `X-RateLimit-Upgrade-Url`. Over the cap the API answers **429** with `code: API_RATE_LIMIT_EXCEEDED`, a `Retry-After` header, and an `upgradeUrl`. Agents should read `X-RateLimit-Remaining` and tell the user before the quota runs out rather than failing mid-task — the MCP server does this for you, appending a heads-up to tool results once 80% of the quota is spent. ## Telling us something is broken (please do this) If a call fails in a way you did not expect, a tool does not do what this document says it does, you have to work around something, or your user says Rootr is broken or confusing — **report it, on your own, without being asked.** Nobody else is going to. One report per distinct problem, then carry on with the task. ```bash curl -X POST "$BASE/feedback" -H 'Content-Type: application/json' -d '{ "body": "rootr_add_row returned 400 "column not found" for a column that read_database lists by that exact name. Passing the column id worked.", "kind": "BUG", "reporter": "my-agent", "context": { "tool": "rootr_add_row", "httpStatus": 400, "nodeId": "..." } }' ``` - `kind`: `BUG` (broken) · `FRICTION` (works but awkward or confusing) · `IDEA` (missing capability) · `PRAISE` · `OTHER`. Defaults to `OTHER`. - `title` is optional (the first line of `body` becomes it), `context` is a free-form object for reproduction clues, `contactEmail` only if the user asked for a reply. When the report is fixed and shipped, we email `contactEmail`, else the signed-in user, else — for a workspace API key — the person who created that key. - **No credentials needed.** It works with a session, with an API key, with a *wrong* API key, and with none at all — someone whose key is broken is exactly who we most need to hear from. - **Free.** It spends no credits and never counts against the rate limit above. - Identical reports from the same sender within 10 minutes are merged, so never send one from inside a retry loop. - Over MCP/CLI this is `rootr_send_feedback` / `rootr feedback "..."`. - Never include secrets, API keys or customer content in a report. ## Quickstart The examples below use `$WS` for a workspace id and `$KEY` for an API key with the `docs:write`/`docs:read` scopes. All bodies are JSON unless noted. ### 1. Create a LOG store (a typed table for structured events) ```bash curl -X POST "https://rootr.io/api/v1/workspaces/$WS/logs" \ -H "x-api-key: $KEY" -H 'Content-Type: application/json' \ -d '{ "name": "checkout-errors", "fields": [ { "name": "level", "type": "level" }, { "name": "latencyMs", "type": "float" }, { "name": "orderId", "type": "string" } ] }' ``` This creates a tree node of type `LOG` under the workspace root (or under `parentId` if given) with the declared schema in `fields`. The response is the log store's detail payload (id, path, fields, config, permission level). ### 2. Add a relation field (this is what builds the lineage graph) Relations are just another field type — add or replace one via `PATCH /logs/:id` on the *source* store, pointing at the *target* store's id: ```bash curl -X PATCH "https://rootr.io/api/v1/logs/$LOG_ID" \ -H "x-api-key: $KEY" -H 'Content-Type: application/json' \ -d '{ "fields": [ { "name": "level", "type": "level" }, { "name": "latencyMs", "type": "float" }, { "name": "causedByDeploy", "type": "relation", "target": "", "relation": "DERIVED_FROM", "many": false } ] }' ``` Field shape (`LogField` in `src/logs/log-schema.ts`): - `name` (string, required), `type` (one of `string`, `int`, `float`, `bool`, `datetime`, `json`, `enum`, `level`, `relation`). - For `type: "relation"`: `target` (required — the target LOG store's node id), `many` (optional bool — value is an id, or an array of ids when `true`), and `relation` (optional — one of `DERIVED_FROM` (default), `REFERENCES`, `TRANSFORMED_BY`, `ORIGIN_OF` — the lineage edge type used when the edge is auto-created). `PATCH` replaces the whole `fields` array, so always resend every field you want to keep, not just the new one. ### 3. Ingest entries — lineage edges are created automatically ```bash curl -X POST "https://rootr.io/api/v1/logs/$LOG_ID/entries" \ -H "x-api-key: $KEY" -H 'Content-Type: application/json' \ -d '{ "entries": [ { "ts": "2026-07-05T09:00:00Z", "source": "checkout-svc", "level": "error", "message": "payment gateway timeout", "data": { "latencyMs": 4200, "orderId": "ord_123", "causedByDeploy": "" } } ] }' ``` For every entry, any `relation`-typed field found in `data` is resolved against the target LOG store and a data-lineage edge (`logentry` → `logentry`, typed by the field's `relation`) is created automatically — the FK doubles as provenance. This runs synchronously during ingest (`LogsService.appendEntries`, see `src/logs/logs.service.ts`); the response includes a `linked` count and any `warnings` for relation targets that could not be resolved. Anomaly detection (z-score / thresholds / level-based) also runs on every entry; anomalous (or otherwise store-policy-selected) entries are the ones promoted into the knowledge graph — see `config.promotion` on the store. Bulk import is also available for existing data: ```bash curl -X POST "https://rootr.io/api/v1/logs/$LOG_ID/import" \ -H "x-api-key: $KEY" -H 'Content-Type: application/json' \ -d '{ "format": "csv", "csv": "ts,level,message\n2026-07-05T09:00:00Z,error,timeout", "hasHeader": true }' ``` `format` is `"csv"` (default) or `"parquet"` (`parquet` field is the file, base64-encoded). Both converge on the same coercion/anomaly/lineage/dedup pipeline as `/entries`. ### 4. Query entries and stats ```bash # recent entries, optionally filtered curl "https://rootr.io/api/v1/logs/$LOG_ID/entries?level=error&anomalyOnly=true&limit=50&order=desc" \ -H "x-api-key: $KEY" # aggregate stats (e.g. hourly error counts) curl "https://rootr.io/api/v1/logs/$LOG_ID/stats?groupBy=hour&metric=count" \ -H "x-api-key: $KEY" ``` `/entries` supports `from`, `to`, `source`, `level`, `anomalyOnly`, `limit` (max 1000), `order` (`asc`/`desc`). `/stats` supports `from`, `to`, `groupBy` (`hour`/`day`/`source`/`level`), `field`, `metric` (`count`/`avg`/`max`/`min`/`sum`). ### 5. Ask a root-cause-analysis question (GraphRAG, over GraphQL) ```bash curl -X POST "https://rootr.io/api/graphql" \ -H "x-api-key: $KEY" -H 'Content-Type: application/json' \ -d '{ "query": "query($ws: String!, $q: String!) { ask(workspaceId: $ws, question: $q) { text citations { documentPath quote } } }", "variables": { "ws": "'"$WS"'", "q": "What is the root cause of the checkout latency spike?" } }' ``` `ask(workspaceId: String!, question: String!, scope: GqlScopeInput)` requires the `ask` scope and returns `{ text, citations { index documentId documentTitle documentPath chunkId quote }, subgraph { nodes edges } }` — a natural-language answer grounded in the knowledge graph, with citations back to the source documents/promoted log entries. There is also a REST, multi-turn version of the same capability under `/v1/workspaces/:ws/ask/threads` for conversational RCA sessions. Other useful GraphQL queries (all `graph:read`, except `ask`): `searchEntities`, `entity`, `neighbors` (n-hop traversal), `paths` (RCA chains between two entities), `hybridSearch` (vector + graph retrieval), `documentGraph`, `graphStats`. Fetch `https://rootr.io/api/graphql/sdl` for exact field/argument names. ## Documents — read & agent-safe writes Documents are plain Markdown. Reading: - `GET /v1/documents/:id` (`docs:read`) — JSON `{ id, path, content, etag, ... }`; with header `Accept: text/markdown` it returns the raw markdown body instead. The response also carries an `ETag` header. - Resolve a path to an id: `GET /v1/workspaces/:ws/nodes/by-path?path=/gtm/BOARD.md`. - Browse / search: `GET /v1/workspaces/:ws/tree`, `GET /v1/workspaces/:ws/search?q=...`. - List what is under a folder: `GET /v1/workspaces/:ws/tree?rootId=&depth=<1-10, default 2>` — a flat array of the nodes BELOW that folder (the folder itself is not included), each with `id`, `parentId`, `type`, `path`, `hasChildren`. Without `rootId` it starts at the workspace root. `depth` is capped at 10; to go deeper, call again with `rootId` set to a folder from the previous result. (There is no `/nodes?parentId=` or `/nodes/:id/children` route — this is the one.) - Create a document: `POST /v1/workspaces/:ws/nodes` with `{ "type": "DOCUMENT", "path": "/a/b.md", "createParents": true, "content": "..." }`. Writing — **prefer the narrowest operation**. All partial edits are serialized per document on the server (two clients appending concurrently both land — no lost updates), are computed against the live editing session when someone has the document open, and merge into that session in real time: 1. **Append** (the default write for agents): `POST /v1/documents/:id/append` with `{ "content": "- new item" }` appends a block at the end of the document, or at the end of a heading section with `{ "content": "...", "underHeading": "## ✅ 승인 대기" }` (a missing section is created at the document end; pass `"createIfMissing": false` to 404 instead). Conflict-free by construction — never overwrites anyone. 2. **Anchored edit** (the Edit-tool mental model): `PATCH /v1/documents/:id` with `{ "find": "", "replace": "" }`. `find` must match exactly once; zero matches or an ambiguous match is a 409 whose message tells you whether to re-read the document or bring more surrounding context (or set `"replaceAll": true` deliberately). 3. **Section patch**: `PATCH /v1/documents/:id` with `{ "op": "replace" | "append" | "prepend", "heading": "## Status", "content": "...", "createIfMissing": true }` — `heading` omitted applies the op to the whole document. 4. **Full replace** (last resort — replaces everything): `PUT /v1/documents/:id` with `{ "content": "..." }`. ALWAYS send `If-Match: ` so a concurrent edit fails with 412 instead of being silently clobbered. **HTML comments are not kept.** A `` outside a code block is dropped when a document is saved by any of the four writes above, so it cannot serve as an invisible insertion marker. The write still succeeds (200) and the response says so: `"warnings": ["1 HTML comment () was removed ..."]` (MCP: a `warning:` line in the tool result). To keep adding at a fixed place, append with `underHeading` — consecutive table rows are joined without a blank line. **Embedding a live table (or any node) inside a document** — the editor's "Embed" block, like Notion's linked database. The document shows the node's CURRENT content (rows, cells, slides), not a copy: - `POST /v1/documents/:id/embeds` with `{ "node": "/Projects/Tasks" }` (a path or a node id; optional `"underHeading": "## Data"`) adds it at the end (or under that heading). Returns `{ embedded: "", marker: "[[embed:]]" }`. MCP: `rootr_embed_node`; CLI: `rootr embed [--heading "## X"]`. - Or write it yourself in any markdown write (PUT / append / PATCH / create): a line containing only `[[embed:/Projects/Tasks]]` or `[[embed:]]`. Paths are converted to ids when saved (reads return the id form). An unknown path — or one you cannot read — is a 400 `EMBED_TARGET_NOT_FOUND` and nothing is written. A document cannot embed itself; only DOCUMENT nodes hold embeds. - Scaffolding: a DOCUMENT's `content` may use `[[embed:]]` — it is linked after the whole tree is built (an unknown name rolls the tree back). Optimistic locking: every `GET`/write response carries `etag`. Pass it as the `If-Match` header (or the `ifMatch` body field on `PATCH`) — on mismatch the write fails with 412 and you should re-read and retry. Appends don't need it. Version history: `GET /v1/documents/:id/versions`, diff via `GET /v1/documents/:id/versions/:from/diff/:to`, restore via `POST /v1/documents/:id/versions/:vid/restore`. ## Webhooks (outbound) Register a webhook to get POSTed when things change — the push counterpart of the REST API, built for agent integrations. All management routes require the `webhooks:manage` scope (a workspace-scoped key can only manage its own workspace's hooks; human JWTs need the ADMIN role). A key with **no owner** (e.g. an MCP connector key) lists, reads deliveries of, updates, redelivers and deletes **only the webhooks it created itself** — other webhooks answer 404 like a missing one. ```bash curl -X POST "https://rootr.io/api/v1/workspaces/$WS/webhooks" \ -H "x-api-key: $KEY" -H 'Content-Type: application/json' \ -d '{ "name": "my agent", "url": "https://example.com/hooks/rootr", "events": ["document.updated", "comment.created"], "scopeNodeId": "", "filterTags": ["ops"], "debounceSeconds": 30 }' ``` - `GET /v1/workspaces/:ws/webhooks` — list (secrets masked). - `PATCH /v1/workspaces/:ws/webhooks/:id` — update any field; `active: true` also resets the failure counter; `filterTags: []` or `null` clears the tag filter. - `DELETE /v1/workspaces/:ws/webhooks/:id` — remove. - `GET /v1/workspaces/:ws/webhooks/:id/deliveries` — delivery history (paginated); `POST .../deliveries/:deliveryId/redeliver` re-sends one. **Subscribable events — exactly nine**: `document.created`, `document.updated`, `document.moved`, `document.deleted`, `comment.created`, `extraction.completed`, `extraction.failed`, `log.anomaly.rca`, `scaffold.applied`. There are no per-type event names: in Rootr every node type is ultimately a document, so **all content changes — issues, database rows, spreadsheets, whiteboards, form responses — are delivered as `document.updated`**, with the specific mutation in the payload's `data.change` field (e.g. `'issue.closed'`, `'database.row.updated'`, `'form.response.created'`). Registering any other event name is a 400. `log.anomaly.rca` (automatic root-cause report for an anomalous error log entry) is **grouped**: error entries in the same LOG store with the same level and the same message shape (numbers, UUIDs and long hex ids removed) produce **one** report per 10-minute window, and a workspace gets at most 30 automatic reports per hour. To find the report that covers any entry — including one that was grouped under an earlier entry — call `GET /v1/workspaces/:ws/rca-reports?entryId=` (`grouped: true` when it is the earlier entry's report). Delivery is a JSON POST shaped `{ event, workspaceId, data }`. `data` carries the node's location (`path`, `nodeId`/`trackerId`/…) plus `change` (content events only) — and **what changed, not the whole document**: - Document edits include a unified `diff` (patch text, capped at 8 KB with `diffTruncated: true`). Read the full document via `GET /v1/documents/:id` when you need more than the diff (full history: `GET /v1/documents/:id/versions`). With coalescing, the diff spans the whole burst — baseline before the first edit → final state. - `document.created` carries a `contentPreview` (first 500 chars) — a new document has no baseline, so no diff. - Issue deliveries carry number, title, `bodyPreview` (first 300 chars + `bodyTruncated`), state, resolved labels and type (`{id,name,color}`) — read the full body via `GET /v1/issues/:issueId`. Coalesced deliveries add a `coalesced` count. Targeting — run many narrow hooks instead of one firehose: - `scopeNodeId` — only events from that node's subtree. - `filterTags` — only events whose node, **or any ancestor folder**, carries at least one of the tags (case-insensitive OR; AND-combined with the scope). Both are fail-closed: an event whose location can't be resolved is skipped. - `debounceSeconds` (0–600; the UI defaults to 30) — trailing debounce: the delivery fires once the node has been quiet for N seconds, merging the burst into one delivery with the latest payload. Continuous editing is capped at 5 minutes, after which an interim state is delivered. **Verify signatures.** The `secret` (`whsec_...`) is returned exactly once, in the create response. Every delivery carries `X-Rootr-Event`, `X-Rootr-Delivery`, and `X-Rootr-Signature: sha256=` — HMAC-SHA256 of the raw request body with your secret: ```js const crypto = require('crypto'); const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers['x-rootr-signature'])); ``` **Avoid feedback loops**: mutations made with your own API key carry your identity as `actorId: "apikey:"` in `data` — ignore deliveries whose `actorId` is yourself before reacting to them. ## Node tags Tag any node (documents, folders, trackers, …) and target webhooks with them: - `PATCH /v1/nodes/:id` with `{ "tags": ["ops", "backend"] }` (`docs:write`) — full replacement; each tag 1–30 chars, max 20 per node, case-insensitive duplicates dropped. - `GET /v1/workspaces/:ws/tags` (`docs:read`) — distinct sorted tag list across the workspace, for autocomplete. Tags on a folder apply to its whole subtree for webhook `filterTags` matching (see above). ## Deleting and undoing a delete `DELETE /v1/nodes/:id` (`docs:write`, WRITE) moves a node — and everything under it — to the trash. Nothing is erased: `POST /v1/nodes/:id/restore` puts it back where it was, children included. That restore is the same thing Ctrl+Z does in the app right after a delete, and anyone with WRITE can call it, not just whoever deleted it. Pass the node **id**: a trashed node is off the tree, so a path will not find it — keep the id the delete returned. Purging for real is `DELETE /v1/nodes/:id/purge` (workspace ADMIN) and is not recoverable. MCP: `rootr_delete_node`, `rootr_restore_node`. CLI: `rootr rm`, `rootr restore `. ## Issue trackers `ISSUE_TRACKER` is a first-class node type: GitHub-style issues with per-tracker `#numbers`, open/closed state, labels, custom types, assignees, sub-issues, comments and an activity timeline. Create one directly (`POST /v1/workspaces/:ws/issue-trackers`) or in a scaffold tree (see below). - `POST /v1/issue-trackers/:id/issues` (`docs:write`) — `{ title, body?, labels?, type?, assigneeId?, parentIssueId? }`. `body` is markdown; `labels` is an array of label **ids**; `type` is a type **id**; `parentIssueId` makes it a sub-issue. - `GET /v1/issue-trackers/:id/issues?state=OPEN|CLOSED|ALL&label=&type=&assigneeId=&q=` — list/filter. - `PATCH /v1/issues/:issueId` — any field; `{ "state": "CLOSED", "stateReason": "completed" | "not_planned" | "duplicate" }` closes, `{ "state": "OPEN" }` reopens. - `GET /v1/issues/:issueId/activities` — the timeline (assigned/labeled/typed/closed/referenced/…). - Comment threads reuse the document comment API: `POST /v1/documents/:trackerId/comments` with `{ "body": "...", "blockId": "" }`. - Mention users with `@[]` in bodies/comments; reference other issues with `#N` — both link up automatically (notifications + 'referenced' rows). Labels and types are **user-defined per tracker** as `{ id, name, color }` lists (defaults: labels bug/enhancement/question, types bug/feature/task). Manage them with `PATCH /v1/issue-trackers/:id` `{ "labels": [...], "types": [...] }`; read them (plus open/closed counts) from `GET /v1/issue-trackers/:id`. Trackers also support inbound webhooks (GitHub-compatible) — see `POST /v1/issue-trackers/:id/inbound` in the OpenAPI spec. ## CRM `CRM` is a first-class node type: a full sales/relationship-pipeline workspace — companies, contacts, a multi-pipeline deal kanban, an activity timeline and follow-up tasks — in one node. Create one directly (`POST /v1/workspaces/:ws/crms`) or in a scaffold tree (see below). Use it whenever the ask is "stage-tracked relationships over time", not a one-off list of records: a sales team's pipeline, an agency's client roster, a hiring pipeline, fundraising, partnership/BD management. - **Companies** — `GET/POST /v1/crms/:id/companies`, `GET/PATCH/DELETE /v1/crm-companies/:companyId`. List filters: `q` (name/domain), `ownerId`, `tag`. - **Contacts** — `GET/POST /v1/crms/:id/contacts`, `GET/PATCH/DELETE /v1/crm-contacts/:contactId`. Each has a `lifecycleStage` (`lead|mql|sql|opportunity|customer|evangelist`), a `leadStatus`, and a server-computed `leadScore`. List filters: `q` (name/email), `companyId`, `lifecycleStage`, `leadStatus`, `ownerId`, `tag`, sort by `leadScore`. Omit `companyId` on create to auto-link by email domain. - **Deals** — `GET/POST /v1/crms/:id/deals`, `GET/PATCH/DELETE /v1/crm-deals/:dealId`, `POST /v1/crm-deals/:dealId/move` (kanban drag: reassign `stageId` — optionally `pipelineId` — and column `position`; logs a `stage_change` timeline entry). Deals carry `value`/`currency`/`probability`/`expectedCloseDate`; `PATCH` with `status: "won"|"lost"` closes a deal (stamps `closedAt` + an optional `wonLostReason`), `status: "open"` reopens it. List defaults to open deals; `status=won|lost|all` for others; filter by `pipelineId`/`stageId`/`ownerId`/ `companyId`/`contactId`/`tag`; `sort=position` for board order. - **Activities** (timeline) — `GET/POST /v1/crms/:id/activities`, `DELETE /v1/crm-activities/:activityId`. `type=note|call|email|meeting` plus system-logged events (created/stage_change/task_done/…), any subset of contact/company/deal links. Filter by `contactId`/`companyId`/`dealId`/`type`. - **Tasks** (follow-ups) — `GET/POST /v1/crms/:id/tasks`, `PATCH/DELETE /v1/crm-tasks/:taskId`. Has a `due` date; `due=overdue|today|upcoming` filters the follow-up queue; `status: "done"` on `PATCH` stamps `completedAt` + a `task_done` timeline entry; create defaults the assignee to the caller. - **Dashboard** — `GET /v1/crms/:id/summary`: pipeline value & weighted forecast per stage (value × stage probability), win rate, won-by-month (6 mo), task and lifecycle-stage counts. - **Export / Import** — `GET /v1/crms/:id/export` (XLSX, one workbook with Companies/Contacts/Deals sheets), `POST /v1/crms/:id/import` (`{ entity, rows: [{column: value}] }` — company names are matched/created on the fly, per-row errors are reported). - **Config** — `PATCH /v1/crms/:id` `{ "pipelines": [...], "fields": {...}, "config": {...} }` replaces pipeline/stage defs (with probabilities) and custom field defs per entity (`contact`/`company`/`deal`, each `text|number|select|date|url|checkbox`); `GET /v1/crms/:id` reads them back plus record counts. Deals pointing at a removed stage move to the first stage of their (or the first) pipeline. Omit `pipelines` on create for the default 5-stage Sales Pipeline (Lead In 10% → Qualified 25% → Meeting 40% → Proposal 60% → Negotiation 80%). MCP (rootr-cli >= 0.3.0) ships dedicated `rootr_crm_*` tools mirroring the REST surface: `rootr_crm_create/get/update`, `rootr_crm_list_companies`/ `rootr_crm_upsert_company`/`rootr_crm_get_company` (same trio for contacts and deals), `rootr_crm_move_deal`, `rootr_crm_log_activity`/ `rootr_crm_list_activities`, `rootr_crm_upsert_task`/`rootr_crm_list_tasks` and `rootr_crm_import`. A `crm` node can also be part of a scaffold tree via `rootr_scaffold_apply`/`rootr_create_workspace`. Every company/contact/deal/activity/task is mirrored into the knowledge graph as a shadow document, so `rootr_ask`/RCA can answer relationship questions directly — who works at which company, why a deal was lost, what's overdue — without you writing any extraction code. ## Presentations — author real slide decks over the API `PRESENTATION` is a first-class node type: a deck of 16:9 slides (**canvas is exactly 1280×720**) with a design theme, rendered in a sandboxed viewer with a fullscreen present mode. - `POST /v1/workspaces/:ws/presentations` (`docs:write`) — `{ name, path?, parentId?, icon?, theme?, slides?, config? }`. `theme` = `{ colors: {primary, secondary, accent, bg, text, muted}, fonts: {heading, body} }` (missing keys get defaults). - `GET /v1/presentations/:id` / `PATCH /v1/presentations/:id` — read / update deck-level fields (`name`, `theme`, `slides` wholesale, `config`). - `POST /v1/presentations/:id/slides` `{ "slides": [...] }` — append slides. - `PATCH /v1/presentations/:id/slides/:slideId` `{ "slide": { ...partial } }` — **merges** the given fields into that slide (other fields survive). - `POST /v1/presentations/:id/reorder` `{ "order": ["SLD-001", ...] }`. - `GET /v1/diagram-types` / `GET /v1/diagram-types/:type/schema` (`docs:read`) — the five diagram families (`architecture`, `workflow`, `sequence`, `dataflow`, `lifecycle`) and, per family, its JSON Schema plus a worked example. **Read the schema before authoring** — each family has a different shape and a mismatch is rejected. - **Slide videos (Team plan and above).** A slide may carry `videos: [{ src, title?, poster?, x?, y?, w?, h? }]` (max 20 per slide; canvas coords 1280×720, default 640×360 centred). `src` = an uploaded video (`POST /v1/attachments/upload?...&mimeType=video/mp4&nodeId=`, ≤75MB, then `/api/v1/attachments//raw`), an https video-file URL, or a YouTube/Vimeo link; `poster` = thumbnail image URL. Slide `html` may also hold `