Custom AI endpoint (custom)
KB_AI_PROVIDER has a custom position — your own service speaking the same language as killBottleneck. Until now that "language" was only written down in our source code, which is not good enough: anyone wanting to plug in their own service had to read it there. Here it is in full.
Most people do not need this page
If you have a key from OpenAI, OpenRouter, Groq and friends, pick openai — it speaks the common shape and there is nothing to build. custom is for when you want your own layer between killBottleneck and the model (your own prompts, routing across several models, request logging, content filtering).
How it works
killBottleneck sends a POST with a JSON body to KB_AI_URL. The body always carries mode (which job this is) and lang (cs or en — the language to answer in; the server decides it from the signed-in user, a client cannot spoof it).
| Method | POST |
| Headers | Content-Type: application/json, X-KB-Token: <KB_AI_TOKEN> |
| Timeout | 120 s (600 s for transcribe) |
| Response | JSON, HTTP 2xx |
You may include "schema_version": 1 in the response. If you send a different number, killBottleneck rejects the answer with a clear error instead of breaking silently. Without the field nothing is checked.
Jobs (mode)
questions — clarifying questions about a goal
// request
{ "mode": "questions", "goal": "Throw a company party", "scope": "detailní", "lang": "en" }
// response
{ "questions": ["How many people?", "By when?", "What budget?"] }Return 1–5 questions; killBottleneck takes the first five.
generate — a plan tree from a goal
// request
{ "mode": "generate", "goal": "Throw a company party", "scope": "detailní",
"answers": ["40 people", "before Christmas", "80k"], "lang": "en" }
// response
{ "nodes": [
{ "id": "root", "title": "Throw a company party", "description": "", "parentId": null },
{ "id": "n1", "title": "Book a venue", "description": "By the end of October.", "parentId": "root" }
] }Rules killBottleneck enforces on the answer:
- exactly one root — the node with
parentId: null; parentIdmust point at anidpresent in the same list (otherwise the node is attached to the root);titleis trimmed to 120 characters;answersholds the replies to the previous step's questions — it may be absent.
scope is one of three values: "stručná", "detailní", "hloubková" (they are values, not user-facing text — they are never translated or changed).
from_text — a plan tree from free text
{ "mode": "from_text", "text": "Meeting notes…", "scope": "detailní", "lang": "en" }The response has the same shape as generate. killBottleneck trims the text to 8,000 characters before sending.
expand — expand a single node
// request
{ "mode": "expand", "goal": "Throw a company party",
"path": ["Throw a company party", "Book a venue"],
"node": { "id": "n1", "title": "Book a venue", "description": "By the end of October." },
"action": "subgoals", "count": 3, "lang": "en" }
// response
{ "nodes": [ { "title": "Visit three places", "description": "Compare price and capacity." } ] }Here the nodes have no id and no parentId — they hang under the node from the request.
action | What is expected |
|---|---|
subgoals | concrete sub-steps for completing the node |
milestones | measurable milestones along the way |
kpi | metrics by which success is measured |
risks | risks (in title) and how to mitigate them (in description) |
rewrite | one node — a better wording of the same thing (count is 1) |
chat — a conversation over the map
// request
{ "mode": "chat", "message": "What is missing from my plan?",
"map": { "title": "Party", "nodes": [
{ "id": "root", "title": "Party", "description": "", "status": "todo", "parentId": null } ] },
"lang": "en" }
// response
{ "reply": "You are missing a budget and a date.", "operations": [] }When the conversation should also change the map, return operations. Anything other than these four is discarded:
{ "op": "add", "parentId": "root", "title": "…", "description": "…" }
{ "op": "update", "id": "n1", "title": "…", "description": "…", "status": "todo|in_progress|done" }
{ "op": "delete", "id": "n1" }
{ "op": "move", "id": "n1", "newParentId": "root" }transcribe — transcribe a recording
Goes to KB_AI_TRANSCRIBE_URL. When that is empty it is derived from KB_AI_URL (a trailing kb-advisor becomes kb-transcribe).
// request
{ "mode": "transcribe", "audio_base64": "…", "filename": "recording.webm", "lang": "en" }
// response
{ "text": "the transcribed text" }Note this is not the OpenAI shape (which wants multipart with a file). If your transcription is OpenAI-compatible, pick the openai provider — it builds the multipart request itself.
Errors
| What you return | What the user sees |
|---|---|
| HTTP 2xx with the expected shape | the result |
HTTP 403 or 429 with { "error": "…", "code": "…" } | your message (quota refusals are passed through unchanged) |
| any other error status | "The AI service returned an error (HTTP …)" |
| unreachable, timeout | "Could not connect to the AI service" |
schema_version ≠ 1 | "Incompatible AI contract version" |
Things to watch
- Answer in the language you were asked for.
langis not decoration — the user gets your text as it is. - Return valid JSON, not text with JSON inside it. killBottleneck can strip a
```jsonwrapper, but there is no reason to rely on that. - An empty answer is an error, not an empty result — prefer an error status with an explanation, so the user learns more than "nothing happened".
- On a hosted instance the address must not point into a private network (otherwise the instance could be used to scan the provider's network). On your own server a private address is perfectly fine.

