Automate

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.

  1. 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
    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.

  2. Attach it to an inbox

    Settings, Inboxes, choose the bot on the inbox (or PUT /inboxes/{id} with bot_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 the assign_bot action.

  3. Receive events

    Kommio POSTs to the bot's URL, signed with Kommio-Signature exactly like a webhook but with the bot's own secret. Answer 2xx quickly and do the work afterwards.

    EventWhen
    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_bot rule action). Same payload as conversation.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) and Kommio-Delivery.

  4. Act through the bot API

    CallActionEffect
    GET /api/bot/conversations/{id}The held conversation with its recent messages.
    POST /api/bot/conversations/{id}/messagesreplyReply as the bot: content, optional content_type (text, html, markdown).
    POST /api/bot/conversations/{id}/typingtypingShow 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 priority or merge custom_attributes.
    POST /api/bot/conversations/{id}/handoffhandoffRelease to people with an optional reason shown in the thread.
    POST /api/bot/conversations/{id}/resolveresolveResolve the conversation. The bot keeps it: if the customer writes again, the bot hears it first.
    POST /api/bot/conversationsstart_conversationStart a conversation with an existing contact: inbox_id, contact_id, the bot's first message as content, optional subject.

    All with Authorization: Bearer <bot token>; bot tokens start with kbt_. A conversation the bot no longer holds answers 404; an action the bot is not allowed answers 403.

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
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.

bot.ts
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);

The payload

POST https://bot.example/kommio
{
  "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
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].