REST API
A small, deliberately narrow API for reading and editing maps from your own code. A task is a node with an assignee or a deadline — there is no separate task object. It is the same surface the MCP server is built on.
Looking for a worked example?
Connecting Google Sheets is the whole recipe step by step — code for Apps Script and for n8n, conflict handling (409) included.
- Works for the cloud and self-hosted alike — a hosted instance (
yourcompany.killbottleneck.com) exposes the same API as one on your server; only the address differs - Base path:
/api/kb/v1— the older/api/flowmap/v1prefix still resolves to the same handlers - Authentication: an API key in the
Authorizationheader. Never a session cookie or JWT - Format: JSON in, JSON out
- Language: English only, by design
Authentication
Create a key in the application, under your user menu. The plaintext token is shown once — only its SHA-256 hash is stored, so a lost key cannot be recovered, only rotated.
Authorization: Bearer kb_user_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXKeys issued before the rename start with fm_user_ and keep working.
Only /api/kb/v1/* is the public API
The app itself talks to many other /api/kb/* routes (/my-day, /portfolio, /export, /import-all, /share, …). Those are internal, authenticated by the browser session, and an API key gets 401 on them. They are described as features, not as endpoints, and may change between versions without notice.
Scopes
| Scope | May call |
|---|---|
read | every GET endpoint |
read_write | everything, including all POST endpoints |
A read key calling a write endpoint gets 403. The default when creating a key is read.
What a key can never do
- The owner comes from the key, never from the request body. A key acts as its owner: it reaches exactly the maps the owner can see in the app — own maps, team maps and maps shared with the owner — and writes according to the share level (
owner/edit= full write;workandread= only thestatusof the owner's own nodes viaPOST …/nodes/{nodeId}, exactly like ticking off in the app; anything else → 403).GET /v1/mapsandGET /v1/maps/{id}return the level asaccess. - A key cannot escalate. The server never establishes a session for it and never reads the account's role, so an API key cannot do administrator things — an admin's key sees no private map of anyone else, and a
read-scope key never writes whatever the share level. - A map the owner cannot see returns 404, not 403 — the API does not confirm that an id exists. Someone else's public map is 404 too: a public board is not working access.
- Assigning an
ownerthrough the API shares the map with that person as a collaborator (work), exactly like the app does (never downgrades, never for external contacts) — but only when the key owner may share at all (map owner or a named editor; a team editor assigns without sharing, as in the app). The response lists who was shared with inshared. - Rules and their run log (
GET …/rules,…/rule-runs) are visible only with edit rights, as in the app; readers and collaborators get 403.
Limits
| Limit | Value |
|---|---|
Requests per minute, read | 120 per key |
Requests per minute, read_write | 30 per key |
| Request body | 2 MB |
| Items per call (nodes) | 200 |
| Keys per account | 20 |
GET /v1/portfolio | 60 per minute per user, on top of the per-key limit |
Read and write are counted in separate buckets, so bulk reading cannot starve your writes. Over the limit you get 429.
Optimistic locking
Every endpoint that changes an existing map requires base_updated — the updated value you got when you last read the map.
GET /api/kb/v1/maps/{id} → { "updated": "2026-07-30 08:12:44.031Z", … }
POST /api/kb/v1/maps/{id}/nodes ← { "base_updated": "2026-07-30 08:12:44.031Z", … }Omit it and you get 400. Send a stale one and you get 409 — someone changed the map in the meantime. Re-read the map, re-apply your change, try again.
This is deliberate: it makes "write without reading first" impossible, which is exactly the mistake an over-eager AI assistant makes.
Endpoints
GET /api/kb/v1/maps
Lists the maps the key owner can see — own, team and shared with the owner. Every item carries access: owner, edit, work or read.
| Query | Meaning |
|---|---|
archived=1 | list archived maps instead of active ones |
{
"maps": [
{ "id": "abc123", "title": "Website relaunch", "node_count": 14, "updated": "2026-07-30 08:12:44.031Z", "access": "owner" }
]
}GET /api/kb/v1/maps/{id}
One map as a tree. No canvas positions — the shape, not the drawing.
{
"id": "abc123",
"title": "Website relaunch",
"description": "",
"archived": false,
"updated": "2026-07-30 08:12:44.031Z",
"access": "owner",
"tree": [ {
"id": "n_1", "type": "goal", "title": "Content", "status": "todo", "description": "",
"deadline": "2026-08-15", "planned_on": "", "owner": "anna@example.com", "color": "",
"wait_for_children": false, "executor_kind": "human", "executor_name": "",
"automation_wanted": false, "automation_note": "", "children": []
} ],
"notes": []
}Keep the updated value — you need it as base_updated for any write.
Node fields
One shape everywhere: what GET …/maps/{id} returns per node is what tree/items accept when creating and what POST …/nodes/{nodeId} accepts when updating. Any other field is a 400 that lists the allowed ones (and, for common names from other tools, the killBottleneck equivalent).
| Field | Type | Meaning |
|---|---|---|
id, type | read-only | type is apex (the root goal) or goal |
title | string, ≤ 200 | required when creating |
status | todo / in_progress / done | default todo; done may unblock waiting nodes and fire rules |
description | string | free text |
deadline | YYYY-MM-DD or "" | an agreement with someone else — nothing in the app moves it silently; "" clears |
planned_on | YYYY-MM-DD or "" | the plan: when the key owner intends to work on it, today to 7 days ahead; "" clears. This is how killBottleneck expresses priority — there is no priority field |
owner | the accountable person: an instance member or an external contact the key owner can see (GET …/members); unknown → 400 with a hint | |
color | #rrggbb or "" | node colour |
wait_for_children | boolean | the node waits until its whole subtree is done |
executor_kind | human / automation | who performs the step (the owner stays a human either way) |
executor_name | string, ≤ 100 | which automation handles it — a record, not an instruction |
automation_wanted | boolean | a wish that this step were automated; notifies the AI agent managers |
automation_note | string, ≤ 1000 | context for the wish |
children | array | tree items only (tree / items), nested recursively; their order is the order on the canvas (left to right) — what Sort in the editor changes |
POST /api/kb/v1/maps
Creates a map from an outline. Requires read_write.
| Field | Required | Meaning |
|---|---|---|
title | yes | Map title, trimmed to 200 characters |
tree | no | Array of items (see below); max 200 nodes in total |
description | no | Free text |
apex_text | no | Text of the root goal; defaults to title |
Each item in tree may carry title, description, deadline, planned_on, owner, status, color, wait_for_children, executor_kind, executor_name, automation_wanted, automation_note and children. Layout is computed for you.
Returns { id, title, updated, tree }.
Unknown fields are rejected, not ignored
Every write endpoint answers 400 to a field it does not know — top-level or inside an item — and the message lists the allowed fields. Common names from other tools get a hint: priority → planned_on, due_date → deadline, assignee → owner, tags → structure or color, reminder / remind_at / time / hour → the reminders endpoint (a node has no time of day; a deadline_approaching rule remains the way to get an untimed alert or one for a whole map), event / meeting → the events endpoint. A typo therefore fails loudly instead of returning 200 and changing nothing.
POST /api/kb/v1/maps/{id}/nodes
Adds a subtree. Requires read_write.
| Field | Required | Meaning |
|---|---|---|
base_updated | yes | Version you read (see optimistic locking) |
items | yes | Array of items, at least one, max 200 |
parent_id | no | Where to attach; omitted = under the apex |
Returns { updated, added_ids, tree }.
This re-computes the layout of the whole map
Adding nodes rearranges positions across the map. It is not a surgical insert.
POST /api/kb/v1/maps/{id}/nodes/{nodeId}
Updates one node. Requires read_write and base_updated.
Settable fields: title, status (todo / in_progress / done), description, deadline (YYYY-MM-DD, empty string clears), planned_on (see below), owner (e-mail, empty string clears), wait_for_children, color, executor_kind, executor_name, automation_wanted, automation_note. Anything else is a 400 with the list of allowed fields.
planned_on is the plan, not the deadline. It says when the key owner intends to work on the node — today to 7 days ahead (YYYY-MM-DD, empty string clears the plan; a date outside that window is a 400). This is how killBottleneck expresses priority: there is no priority field on purpose, and the deadline is an agreement with someone else that must never be moved to say "this is important". A node planned for today shows up in My day exactly as if the person had planned it in the app. Every read (GET …/maps/{id}, the tree in write responses) returns planned_on as well.
Returns { updated, shared, node } — the new map version, who got the map shared because of a new owner, and the stored node (id, title, status, deadline, planned_on, owner, executor_kind, executor_name, automation_wanted). Read the value back from there: what the response shows is what the map holds.
Setting status to done has side effects on purpose: it may unblock waiting nodes, notify their owners, and trigger automations attached to them.
POST /api/kb/v1/maps/{id}/nodes/{nodeId}/delete
Deletes a node including its whole subtree. Requires read_write and base_updated.
The apex cannot be deleted, and whole maps cannot be deleted through the API at all. There is no undo — read the map and check the id first.
GET /api/kb/v1/maps/{id}/rules
Automation rules of the map (“when X → do Y”) — id, name, enabled, trigger, conditions, actions, last_fired (last time-based run — schedule/deadline_approaching; empty for event rules) and last_error (a non-empty error means the rule is broken and the map owner has been notified).
POST /api/kb/v1/maps/{id}/rules
Creates a rule. Requires read_write. Limits are structural only: 50 rules per map, 10 actions and 20 conditions per rule — runs are never counted or billed. A rule applies to future events only, never retroactively.
| Field | Required | Meaning |
|---|---|---|
name | yes | rule name (max 120 chars) |
trigger | yes | {"type": …} — node_status_changed (optional status), node_unblocked, deadline_approaching (when: before/overdue, days 0–365), node_created, file_uploaded, schedule (freq: daily/weekly, weekday 1–7, hour 0–23) |
actions | yes | array of 1–10 actions, executed in order: set_status, set_owner (member e-mail or the dynamic target deputy_of_node_owner), set_deadline (date, relative_days, or advance = daily/weekly/monthly — advances the node's current deadline by one interval, keeping the rhythm anchored to the original deadline: every Monday stays a Monday, the 31st stays the 31st clamped in shorter months; past occurrences skip to the nearest future one) — these three accept target: trigger_node (default) / parent / a node id, move_node (to = id of the new parent; moves the TRIGGER node and appends it at the end of its new row — the kanban move; the apex, a vanished target or a cycle = an acknowledged skip in the log), create_subnodes (items = same tree as add_nodes, max 50 nodes), notify (to: node_owner/deputy_of_node_owner/map_owner/e-mail, message), run_agent (agent_name from the registry) |
conditions | no | AND chain of {field, op, value} — field: status/owner/deadline/executor_kind/parent (id of the parent node — "card under a column", eq/ne only); op: eq/ne/empty/not_empty/before/after (deadline only, YYYY-MM-DD) |
node_id | no | scope the rule to one node; required for schedule rules with node-targeted actions |
enabled | no | defaults to true |
Scheduled triggers run within the hour after the configured hour (server local time) — no “exactly at midnight” promise; after an outage they catch up the same day. deadline_approaching with when=overdue fires when the deadline is at least days past — it also catches deadlines that expired before the rule existed — and fires once per deadline (a changed deadline may fire again); when=before fires on the exact day (deadline − days). Rule chaining (an action firing another rule) is allowed up to depth 3, then the run ends with an explicit skipped log entry.
Dynamic targets resolve when the rule runs, not when the rule is saved — staff changes never break a rule. deputy_of_node_owner = the deputy of the trigger node's responsible person: org-structure position deputies take precedence (several positions with different deputies → notify goes to all of them, set_owner is skipped with advice to target a specific position), the personal deputy from Organization settings is the fallback. position:<nodeId> / deputy_of_position:<nodeId> target the holder/deputy of an org-structure position (GET /v1/org-structure lists the ids). An unresolvable target (no deputy, vacant or deleted position) skips the action and the run log says so — the rule is not marked as broken. Targets derived from the trigger node need node_id on a map-level schedule rule; position: targets do not.
POST /api/kb/v1/maps/{id}/rules/{ruleId}
Updates a rule. Passing only {"enabled": true/false} toggles it; otherwise send the full new shape (partial field edits are not merged). Editing clears the rule's error state.
POST /api/kb/v1/maps/{id}/rules/{ruleId}/delete
Deletes a rule. Its run log stays (with a name snapshot).
GET /api/kb/v1/maps/{id}/rule-runs
Run log of the map's rules (newest first, max 100). ?rule= limits to one rule. status: ok / failed / skipped (loop safety stop or per-save cap — detail says which).
GET /api/kb/v1/rule-templates
Instance-wide library of rule templates (rule shape without a map or node scope). To “load” a template, send its trigger/conditions/actions to POST …/rules of the target map — an independent copy is created.
POST /api/kb/v1/rule-templates
Creates (or with id updates — author or admin only) a template: name (unique), trigger, actions, optional conditions. A template must not carry node_id and create_subnodes may only target trigger_node. Requires read_write.
POST /api/kb/v1/rule-templates/{id}/delete
Deletes a template (author or admin only). Rules already loaded from it stay in their maps.
GET /api/kb/v1/portfolio
Limit: 60 per minute per user (on top of the per-key limit).
The view from above — the same JSON as the Organization page (counts, scope, sections.projects / overdue / stuck / people / changes, truncated), computed over the team and shared maps the key owner can read; the owner's private maps are excluded even from totals. Optional ?today=YYYY-MM-DD. The role is not read: a plain member gets the summary of their own maps (in the app the page itself is admin/manager only). MCP: get_portfolio.
GET /api/kb/v1/members
Who you can assign work to: the instance members and the external contacts the key owner can see. Response: {members: [{id, email, full_name, name, role}], external_contacts: [{id, name, owner_email}]} — a safe subset, never notification settings or secrets. Use email (or an external contact's pseudo-address ext-<id>@kontakt.invalid from owner_email) as the owner of a node; an unknown e-mail is rejected with 400 and a hint of who you probably meant. MCP: list_people.
GET /api/kb/v1/org-structure
The organization structure (the org map): positions and functions with node ids, holders and deputies. Response: {exists, map_id, positions: [{node_id, title, position_kind, holder, deputy}]} where position_kind is position (defined by the structure) or function (appointed). Use node_id as the dynamic rule target position:<nodeId> (holder) or deputy_of_position:<nodeId> (deputy of that position) — both resolve when the rule runs, so staff changes never break a rule. An archived org map counts as "no structure" everywhere (exists: false, position targets skip). Read-only: holders and deputies are appointed by an admin in the app (Organization settings) — the API-key contract never reads user roles, so there is no write endpoint here.
Events
An event is a personal calendar item with a time — a meeting, a call, the dentist. It is not a task: it belongs to no map, has no status or assignee, and changes nothing in any project. In the app it lives in the calendar (/tasks?view=calendar) under the Personal filter. Times are the instance's local time (TZ), as YYYY-MM-DD plus HH:MM strings — never ISO timestamps with an offset.
The key owner sees their own events and those they were invited to; creates, edits and deletes only their own. MCP: create_event, list_events.
Event object (returned everywhere):
{
"id": "ev1", "title": "Dentist", "day": "2026-09-20", "time": "14:00", "note": "",
"owner_email": "me@example.com", "participants": ["anna@example.com"],
"remind": true, "remind_before_min": 30, "mine": true,
"created": "2026-09-19 10:01:12.004Z", "updated": "2026-09-19 10:01:12.004Z"
}time is "" for an all-day event; participants are member e-mails; mine says whether the key owner created it.
GET /api/kb/v1/events
Events in a day window — own and invited, ordered by day and time.
| Query | Meaning |
|---|---|
from, to | YYYY-MM-DD, inclusive; default today − 365 … today + 730 |
Returns { events: [...], from, to }.
POST /api/kb/v1/events
Creates an event. Requires read_write.
| Field | Required | Meaning |
|---|---|---|
title | yes | ≤ 200 characters |
day | yes | YYYY-MM-DD |
time | no | HH:MM (24 h); omit or "" = all day |
note | no | ≤ 2000 characters |
participants | no | array of member e-mails (max 50). Instance members only — an unknown address or an external contact is a 400 with a hint (GET …/members). Each invitee gets an event invited notification and sees the event in their calendar |
remind | no | boolean; a reminder for the owner and all invitees |
remind_before_min | no | 0–10080 minutes before the start (0 = at the start); sending it implies remind: true, remind: true without it means 30. All-day event: the reminder arrives in the morning at KB_DEADLINE_HOUR |
Returns { event }. No base_updated — events are not part of a map.
POST /api/kb/v1/events/{id}
Updates an event; send only the fields to change (same fields as when creating). Owner only: an invitee gets 403, an event the key owner cannot see is 404. Changing the day, time or reminder re-arms the reminder; new invitees are notified, existing ones are not notified again.
POST /api/kb/v1/events/{id}/delete
Deletes an event. Owner only (403 / 404 as above).
POST /api/kb/v1/events/{id}/leave
Removes the key owner from the participants of an event they were invited to (the event disappears from their calendar). The creator cannot leave (400) — they delete instead; not invited → 404.
Reminders
A timed reminder on a node, relative to its deadline: offset_days before the deadline (0 = on the deadline day, 1 = the day before …) at time. It is private to the key owner (the node stays as it is for everyone else), there is one per person and node, and it never changes the deadline — when the deadline moves, the reminder moves with it. A node that is done, deleted or loses its deadline drops the reminder. The map only has to be visible to the key owner; no base_updated, because nothing in the map changes. MCP: create_reminder.
GET /api/kb/v1/maps/{id}/nodes/{nodeId}/reminders
The key owner's reminder on the node — { reminders: [...] }, empty or one item.
POST /api/kb/v1/maps/{id}/nodes/{nodeId}/reminders
Creates or replaces the reminder (upsert). Requires read_write.
| Field | Required | Meaning |
|---|---|---|
offset_days | no | integer 0–30, default 0 (the deadline day) |
time | yes | HH:MM (24 h), instance local time |
Returns the stored reminder, the node's title and its deadline:
{
"reminder": { "id": "r1", "map_id": "abc123", "node_id": "n_1", "offset_days": 1, "time": "09:00",
"day": "2026-08-14", "fires_at": "2026-08-14 09:00", "fired": false },
"node_title": "Draft the copy", "deadline": "2026-08-15"
}Errors: the node has no deadline → 400 (set one via POST …/nodes/{nodeId} first), the computed time is already in the past → 400, unknown node → 404.
POST /api/kb/v1/maps/{id}/nodes/{nodeId}/reminders/{rid}/delete
Removes the reminder. Someone else's reminder is 404.
Delivery for both: the server checks every minute and sends a reminder notification to the bell and by e-mail (e-mail is on by default for this type and goes out even in daily-digest mode). After an outage, reminders older than KB_REMINDER_CATCHUP_H hours (default 48) are logged instead of sent. See Notifications.
/api/kb/v1/tasks — removed
The task endpoints (GET/POST /v1/tasks, POST /v1/tasks/{id}) were removed and now return 410 Gone. In killBottleneck a task is not a separate record: a task is a node with an assignee (owner) or a deadline. New work = a new node. Create and update work through /v1/maps/{id}/nodes (MCP: add_nodes, update_node); a node completes by setting its status to done.
A worked example
KEY="kb_user_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
HOST="http://localhost:8090"
# 1) find a map
curl -s -H "Authorization: Bearer $KEY" "$HOST/api/kb/v1/maps"
# 2) read it — and keep `updated`
MAP=abc123
UPDATED=$(curl -s -H "Authorization: Bearer $KEY" "$HOST/api/kb/v1/maps/$MAP" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["updated"])')
# 3) add two goals under the apex
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d "{\"base_updated\":\"$UPDATED\",\"items\":[
{\"title\":\"Draft the copy\",\"deadline\":\"2026-08-15\"},
{\"title\":\"Shoot the photos\"}]}" \
"$HOST/api/kb/v1/maps/$MAP/nodes"
# 4) "this is the priority" = plan it for today (re-read `updated` first — the map just changed)
NODE=n_1
UPDATED=$(curl -s -H "Authorization: Bearer $KEY" "$HOST/api/kb/v1/maps/$MAP" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["updated"])')
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d "{\"base_updated\":\"$UPDATED\",\"planned_on\":\"$(date +%F)\"}" \
"$HOST/api/kb/v1/maps/$MAP/nodes/$NODE"
# → 200, the goal is in My day under "Today"; its deadline did not move
# 5) a field the API does not know is an error, never a silent no-op
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d "{\"base_updated\":\"$UPDATED\",\"priority\":\"high\"}" \
"$HOST/api/kb/v1/maps/$MAP/nodes/$NODE"
# → 400 {"error":"Unknown fields: priority. Allowed fields: title, status, description, deadline,
# planned_on, owner, … There is no \"priority\" in killBottleneck — priority is expressed by
# the plan planned_on (WHEN you intend to work on it, today to +7 days). …"}Error codes
See Error codes for the full table.

