Build an agent bot
A bot holds an inbox: Kommio posts each new conversation and customer message to your service, and your service replies or hands off through a small API. A complete echo bot in Node and Python.
Last updated
When to use a bot, when an app
A bot owns conversations for a while: it answers first and decides when a person takes over. An app observes and can veto or rewrite what agents send, and renders panels. Use a bot for automated first response; use an app for guardrails and context.
Create the bot
Settings › Marketplace › Installed › Agent bots › New agent bot (permission
bots:manage), or the API. The token and the signing secret are returned once. The bot is an installation of this workspace alone, listed under Installed with its health.curl -X POST "$KOMMIO_URL/bots" -H "Authorization: Bearer $KOMMIO_KEY" -H "Content-Type: application/json" \ -d '{"name": "Order bot", "outbound_url": "https://bot.example/kommio"}'Response
201 Created{ "id": 2, "name": "Order bot", "outbound_url": "https://bot.example/kommio", "status": "active", "allowed_actions": ["reply", "handoff", "update", "typing", "resolve"], "auto_pause_after": 0, "health": { "state": "idle", "consecutive_failures": 0, "delivered": 0, "failed": 0 }, "token": "kbt_9Qm1xZ2aP4rT7wK0nL3vB6cY8dF5hJ2", "secret": "whsec_2jH7kL4mP9qR1sT6vW3xY8zA0bC5dE", "token_prefix": "kbt_9Qm1xZ2a" }The token and the secret are in this response only; the list shows the prefix.
Attach it to an inbox
Settings, Inboxes, choose the bot on the inbox (or
PUT /inboxes/{id}withbot_id). One bot per inbox. New conversations there are held by the bot until it hands off, until it resolves them, or until a person replies or takes over. An automation rule can also give an existing conversation to the inbox's bot with theassign_botaction.Receive events
Kommio POSTs to the bot's URL, signed with
Kommio-Signatureexactly like a webhook but with the bot's own secret. Answer 2xx quickly and do the work afterwards.Event When conversation.createdA new conversation arrived in an inbox the bot is attached to. Not sent for conversations the bot started itself. conversation.assignedAn existing conversation was given to the bot (the assign_botrule action). Same payload asconversation.created.message.createdThe customer replied in a conversation the bot holds. Only inbound replies: the bot never hears its own messages, notes or activity lines. Headers:
Kommio-Signature,Kommio-Bot-Event(the event name),Kommio-Event-Id(the same on every retry, so deduplicate on it) andKommio-Delivery.Act through the bot API
Call Action Effect GET /api/bot/ conversations/ {id} The held conversation with its recent messages. POST /api/bot/ conversations/ {id}/ messages replyReply as the bot: content, optionalcontent_type(text,html,markdown).POST /api/bot/ conversations/ {id}/ typing typingShow the bot as typing to the customer and to agents. Limited to 20 a minute per conversation; more answers 429.PATCH /api/bot/ conversations/ {id} updateSet priorityor mergecustom_attributes.POST /api/bot/ conversations/ {id}/ handoff handoffRelease to people with an optional reasonshown in the thread.POST /api/bot/ conversations/ {id}/ resolve resolveResolve the conversation. The bot keeps it: if the customer writes again, the bot hears it first. POST /api/bot/ conversations start_conversationStart a conversation with an existing contact: inbox_id,contact_id, the bot's first message ascontent, optionalsubject.All with
Authorization: Bearer <bot token>; bot tokens start withkbt_. A conversation the bot no longer holds answers404; an action the bot is not allowed answers403.
Allowed actions
Each bot has a list of callback actions it may use, shown to it in every payload as allowed_actions. A new bot gets reply, handoff, update, typing and resolve; start_conversation is off until an administrator turns it on. handoff is always kept, so a bot can never trap a conversation. Change the list on the bot's page under Settings › Marketplace › Installed › Agent bots, or with PUT /bots/{id}:
curl -X PUT "$KOMMIO_URL/bots/2" -H "Authorization: Bearer $KOMMIO_KEY" -H "Content-Type: application/json" \
-d '{"name": "Order bot", "outbound_url": "https://bot.example/kommio",
"allowed_actions": ["reply", "handoff", "typing", "start_conversation"]}'A bot may start conversations only in inboxes it is attached to. A website chat can be started only with a signed-in visitor (a contact with a signed identity), an email inbox only with a contact that has an email address, and channels where the customer has to write first refuse with 422.
A complete bot
A runnable one with tests is the echo bot example (TypeScript, no dependencies); the versions below show the same shape in several languages.
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const TOKEN = process.env.KOMMIO_BOT_TOKEN!, SECRET = process.env.KOMMIO_BOT_SECRET!;
function verify(header = "", body: string) {
const p = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]));
const expected = createHmac("sha256", SECRET).update(p.t + "." + body).digest("hex");
return Math.abs(Date.now() / 1000 - Number(p.t)) < 300 && expected.length === (p.v1 || "").length && timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1));
}
async function bot(path: string, method: string, body?: unknown) {
return fetch(process.env.KOMMIO_URL + "/bot" + path, { method, headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
}
createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", async () => {
if (!verify(req.headers["kommio-signature"] as string, raw)) { res.writeHead(401); return res.end(); }
res.writeHead(204); res.end();
const ev = JSON.parse(raw);
const id = ev.conversation.id;
if (ev.event === "conversation.created" || ev.event === "conversation.assigned") {
await bot(`/conversations/${id}/messages`, "POST", { content: "Hi! I can look up an order for you. What is the order number?" });
return;
}
if (ev.allowed_actions.includes("typing")) await bot(`/conversations/${id}/typing`, "POST");
const text: string = ev.message?.content ?? "";
const m = text.match(/\b(\d{4,})\b/);
if (m) {
const order = await lookupOrder(m[1]);
await bot(`/conversations/${id}/messages`, "POST", { content: order ? `Order ${m[1]} is ${order.status}, arriving ${order.eta}.` : `I could not find order ${m[1]}.` });
await bot(`/conversations/${id}`, "PATCH", { custom_attributes: { order_id: m[1] } });
} else {
await bot(`/conversations/${id}/handoff`, "POST", { reason: "Customer needs a person" });
}
});
}).listen(8788);import hmac, hashlib, os, re, time, requests
from flask import Flask, request, abort
URL, TOKEN, SECRET = os.environ["KOMMIO_URL"], os.environ["KOMMIO_BOT_TOKEN"], os.environ["KOMMIO_BOT_SECRET"].encode()
app = Flask(__name__)
H = {"Authorization": f"Bearer {TOKEN}"}
def verify(header, body):
p = dict(kv.split("=", 1) for kv in header.split(","))
exp = hmac.new(SECRET, f"{p['t']}.".encode() + body, hashlib.sha256).hexdigest()
return abs(time.time() - int(p["t"])) < 300 and hmac.compare_digest(exp, p.get("v1", ""))
@app.post("/kommio")
def hook():
body = request.get_data()
if not verify(request.headers.get("Kommio-Signature", ""), body):
abort(401)
ev = request.get_json(force=True)
cid = ev["conversation"]["id"]
if ev["event"] in ("conversation.created", "conversation.assigned"):
requests.post(f"{URL}/bot/conversations/{cid}/messages", headers=H, json={"content": "Hi! What is your order number?"})
return "", 204
m = re.search(r"\b(\d{4,})\b", ev.get("message", {}).get("content", ""))
if m:
order = lookup_order(m.group(1))
text = f"Order {m.group(1)} is {order['status']}, arriving {order['eta']}." if order else f"I could not find order {m.group(1)}."
requests.post(f"{URL}/bot/conversations/{cid}/messages", headers=H, json={"content": text})
else:
requests.post(f"{URL}/bot/conversations/{cid}/handoff", headers=H, json={"reason": "Customer needs a person"})
return "", 204The payload
{
"event": "message.created",
"event_id": 91822,
"account_id": 12,
"occurred_at": "2026-09-22T09:12:00Z",
"conversation": { "id": 4821, "display_id": 231, "inbox_id": 3, "status": "open", "priority": "none", "subject": "Order 4242",
"contact": { "id": 77, "name": "Ana", "email": "[email protected]" }, "custom_attributes": {},
"created_at": "2026-09-22T09:10:00Z" },
"message": { "id": 55010, "direction": "inbound", "kind": "reply", "sender_type": "contact", "content": "where is my order 4242?",
"content_type": "text", "created_at": "2026-09-22T09:12:00Z" },
"callback_api": "https://app.kommio.io/api/bot",
"allowed_actions": ["reply", "handoff", "update", "typing", "resolve"],
"visitor": { "landing_page": "https://shop.example/?utm_source=news",
"current_page": { "url": "https://shop.example/orders/4242", "title": "Order 4242", "at": "2026-09-22T09:11:00Z" } }
}message is present on message.created only. For a website chat that has visitor context, every event carries it under visitor, in the same shape as the webhook payload, read when the request is built.
Deliveries, retries and replay
Every notification to a bot is a row in the same delivery ledger webhooks use, with attempts, the response code, the first 4 KB of the response and the latency; the stored request body is redacted for whoever reads the ledger, by their role's field grants. A failed delivery is retried after 30 seconds, 2 minutes and 10 minutes, then marked failed: a customer in a chat will not wait a day. A delivery is cancelled once the bot is paused or no longer holds the conversation.
# recent deliveries, or only the failed ones
curl "$KOMMIO_URL/bots/2/deliveries?status=failed" -H "Authorization: Bearer $KOMMIO_KEY"
# send one again now (only while the bot still holds the conversation)
curl -X POST "$KOMMIO_URL/bots/2/deliveries/1204/replay" -H "Authorization: Bearer $KOMMIO_KEY"
# after fixing the bot: send the failed ones again (the oldest 100, spaced out; at most once a minute)
curl -X POST "$KOMMIO_URL/bots/2/deliveries/replay-failed" -H "Authorization: Bearer $KOMMIO_KEY"status filters by pending, retrying, delivered, failed or cancelled. "Replay all failed" cancels the deliveries for conversations the bot no longer holds instead of sending them.
Health and auto-pause
GET /bots and GET /bots/{id} include health, computed from the bot's latest attempts in the last 24 hours: idle (nothing recent), healthy, degraded (the latest attempt failed, or at least 20% of recent attempts did) or failing (5 or more failures in a row), with counts, the last error and when the bot last received something. The bot's page and the Installed table show it with a warning.
By default Kommio never pauses a bot on its own, because the conversations it holds would then wait for people. An administrator can opt in per bot with auto_pause_after: 0 (off, the default) or 3 to 50 failed deliveries in a row, counted since the bot was last resumed. When it triggers, the bot is paused, the pause is recorded in the audit log, and everyone with bots:manage gets an in-app notification and an email. auto_paused_at says when; resuming the bot (status: "active") clears it.
Reporting
Each stretch of time a bot holds a conversation is recorded with how it ended: an explicit hand-off, an implicit one (a person replied), a take-over from the thread, or a release. Reports, Bots and each bot's page show conversations handled, resolved by the bot without a person, hand-offs by kind, the hand-off rate and the median and p90 time to hand-off. The same numbers are in the API:
curl "$KOMMIO_URL/reports/bots?from=2026-09-01&to=2026-09-30&tz=Europe/Lisbon&bot_id=2" -H "Authorization: Bearer $KOMMIO_KEY"Response 200 OK
{ "from": "2026-09-01", "to": "2026-09-30", "tz": "Europe/Lisbon",
"total": { "bot_id": 0, "label": "All bots", "handled": 412, "resolved": 171, "handoffs": 226, "handoffs_explicit": 190,
"handoffs_implicit": 21, "handoffs_takeover": 15, "handoff_rate": 0.55, "resolution_rate": 0.42,
"handoff_median_ms": 94000, "handoff_p90_ms": 410000, "handoff_time_count": 226 },
"rows": [ { "bot_id": 2, "label": "Order bot", "handled": 412, "resolved": 171, "handoffs": 226, "handoffs_explicit": 190,
"handoffs_implicit": 21, "handoffs_takeover": 15, "handoff_rate": 0.55, "resolution_rate": 0.42,
"handoff_median_ms": 94000, "handoff_p90_ms": 410000, "handoff_time_count": 226 } ],
"note": "<a line the page shows under the table>" }Every claim on these pages is covered by a test in the repository, and the API reference is generated from the same contract every server serves at /api/openapi. Corrections to [email protected].