Echo bot: a sample agent bot in TypeScript
A complete Kommio agent bot with no dependencies, for Node 22: it greets new conversations, echoes what the customer writes, hands off to a person when asked, verifies every delivery and only does what the event allows. Pure logic in one file, the HTTP server in another, tests for both.
Last updated
What it is
An agent bot holds an inbox: Kommio posts each new conversation and customer message to your service, and your service replies, shows typing or hands off through a callback API. This sample is the smallest bot that does all of it:
- When a conversation starts, or an automation rule assigns one to the bot, it greets the customer and says they can type "human".
- When the customer replies, it echoes the message back as
You said: <the message>. - When the message contains the word "human", it says "Connecting you with a person." and hands the conversation off to the team.
- It ignores outbound messages, private notes and activity messages, and it only does what the event's
allowed_actionspermits.
| File | What it holds |
|---|---|
bot.ts | Pure logic: verifySignature, decide(payload) (the plan for an event) and the Kommio callback client. No I/O except the calls to Kommio, so it is unit-tested with node:test |
server.ts | A node:http server: verifies, acknowledges, then runs the plan |
bot.test.ts | Tests, including one against a local stub of the callback API |
When to use it
Copy this when something should answer customers before a person does: collect an order number, answer from your own system, triage by keyword, and hand off when it cannot help. Replace decide with your own rules or a call to a model; keep the shape. If a person will always answer and you only need to react to events, a webhook receiver is the right tool; for a flow built without code, see the automation guide.
Run it
Create the bot
Settings › Marketplace › Installed › Agent bots → New agent bot, and set its URL to where this server listens, for example
http://127.0.0.1:4568/. Kommio shows the bot's token and signing secret once; copy both.Start it
PORTdefaults to 4568;GET /healthzanswers 200.KOMMIO_BOT_TOKEN=<bot token> KOMMIO_BOT_SECRET=<signing secret> PORT=4568 \ node --experimental-strip-types examples/bots/echo-ts/server.tsAllow the address
Kommio normally refuses to deliver to private addresses such as
127.0.0.1. In development mode they are allowed; otherwise setKOMMIO_OUTBOUND_ALLOW_PRIVATEon the Kommio server, or expose the bot through a tunnel.Attach it to an inbox
New conversations in that inbox now go to the bot, which also receives conversations that an automation rule assigns to it.
What Kommio sends
Each event is a POST to the bot URL with a JSON body:
{
"event": "message.created",
"event_id": 123,
"account_id": 1,
"occurred_at": "2026-09-24T10:00:00Z",
"conversation": {
"id": 42, "display_id": 7, "inbox_id": 3, "status": "open", "priority": "none",
"subject": "optional",
"contact": { "id": 9, "name": "Ada", "email": "optional", "phone": "optional" },
"custom_attributes": {},
"created_at": "2026-09-24T10:00:00Z"
},
"message": {
"id": 100, "direction": "inbound",
"kind": "reply",
"sender_type": "contact", "content": "hello", "content_type": "text",
"created_at": "2026-09-24T10:00:00Z"
},
"callback_api": "https://kommio.example/api/v1/bot",
"allowed_actions": ["typing", "reply", "handoff"]
}The events are conversation.created (a new conversation arrived in an inbox the bot is attached to), conversation.assigned (an automation rule assigned a conversation to the bot) and message.created (a message was added to one of the bot's conversations; the message object is present only then). The headers:
Kommio-Signature:t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with the bot's signing secret. Check it against the raw bytes before parsing, compare in constant time, and reject timestamps more than five minutes old.Kommio-Bot-Event: the event name.Kommio-Event-Id: the event id. It stays the same across retries, so use it to de-duplicate.Kommio-Delivery: the id of this delivery attempt.
Calling back
Every call to callback_api carries Authorization: Bearer <KOMMIO_BOT_TOKEN> and Content-Type: application/json:
| Call | Body | What it does |
|---|---|---|
GET {callback_api}/conversations/{id} | Reads the conversation | |
POST {callback_api}/conversations/{id}/messages | {"content": "<text>"} | Posts a reply (reply) |
POST {callback_api}/conversations/{id}/typing | none | Shows a typing indicator (typing) |
POST {callback_api}/conversations/{id}/handoff | {"reason": "<why>"} | Hands off to the team (handoff) |
POST {callback_api}/conversations/{id}/resolve | Resolves the conversation | |
PATCH {callback_api}/conversations/{id} | fields to change | Updates the conversation |
POST {callback_api}/conversations | a new conversation | Starts a conversation; only when allowed_actions contains start_conversation |
The code
// Pure logic for the echo bot: signature check, the plan for an event, and a
// tiny client for Kommio's bot callback API. No dependencies; the server in
// server.ts wires these together. Kept free of I/O (except Kommio) so it can
// be unit-tested with node:test.
import { createHmac, timingSafeEqual } from 'node:crypto';
export type BotEvent = 'conversation.created' | 'conversation.assigned' | 'message.created';
export interface Payload {
event: BotEvent | string;
event_id: number;
account_id: number;
occurred_at: string;
conversation: {
id: number;
display_id: number;
inbox_id: number;
status: string;
priority: string;
subject?: string;
contact: { id: number; name: string; email?: string; phone?: string };
custom_attributes: Record<string, unknown>;
created_at: string;
};
message?: {
id: number;
direction: 'inbound' | 'outbound';
kind: 'reply' | 'note' | 'activity';
sender_type: string;
content: string;
content_type: string;
created_at: string;
};
callback_api: string;
allowed_actions: string[];
}
export type Action =
| { kind: 'typing' }
| { kind: 'reply'; content: string }
| { kind: 'handoff'; reason: string };
export const GREETING = 'Hi! I am the echo bot. I will repeat what you say. Type "human" at any time to talk to a person.';
export const HANDOFF_REPLY = 'Connecting you with a person.';
/**
* Kommio-Signature: `t=<unix>,v1=<hex hmac-sha256 of "<t>.<body>">`.
* Rejects timestamps more than `toleranceSec` away from `nowSec`.
*/
export function verifySignature(secret: string, header: string | undefined | null, body: string, nowSec: number, toleranceSec = 300): boolean {
if (!secret || !header) return false;
let t = '';
let v1 = '';
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i < 0) continue;
const k = part.slice(0, i).trim();
const v = part.slice(i + 1).trim();
if (k === 't') t = v;
else if (k === 'v1') v1 = v;
}
if (!/^\d+$/.test(t) || !/^[0-9a-f]+$/i.test(v1)) return false;
if (Math.abs(nowSec - Number(t)) > toleranceSec) return false;
const want = Buffer.from(createHmac('sha256', secret).update(`${t}.${body}`).digest('hex'));
const got = Buffer.from(v1.toLowerCase());
return got.length === want.length && timingSafeEqual(got, want);
}
/** The plan for one event, filtered to what Kommio allows this bot to do. */
export function decide(p: Payload): Action[] {
let plan: Action[] = [];
if (p.event === 'conversation.created' || p.event === 'conversation.assigned') {
plan = [{ kind: 'typing' }, { kind: 'reply', content: GREETING }];
} else if (p.event === 'message.created') {
const m = p.message;
if (!m || m.direction !== 'inbound' || m.kind !== 'reply') return [];
const text = m.content ?? '';
plan = /\bhuman\b/i.test(text)
? [{ kind: 'typing' }, { kind: 'reply', content: HANDOFF_REPLY }, { kind: 'handoff', reason: 'customer asked for a human' }]
: [{ kind: 'typing' }, { kind: 'reply', content: `You said: ${text}` }];
}
const allowed = new Set(p.allowed_actions ?? []);
return plan.filter((a) => allowed.has(a.kind));
}
/** Minimal client for the bot callback API. */
export class Kommio {
readonly base: string;
readonly token: string;
constructor(callbackApi: string, token: string) {
this.base = callbackApi.replace(/\/+$/, '');
this.token = token;
}
private async post(path: string, body?: unknown): Promise<void> {
const res = await fetch(`${this.base}${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) throw new Error(`POST ${path} -> ${res.status} ${await res.text().catch(() => '')}`);
}
typing(conversationId: number): Promise<void> { return this.post(`/conversations/${conversationId}/typing`); }
reply(conversationId: number, content: string): Promise<void> { return this.post(`/conversations/${conversationId}/messages`, { content }); }
handoff(conversationId: number, reason: string): Promise<void> { return this.post(`/conversations/${conversationId}/handoff`, { reason }); }
/** Runs a plan in order; stops at the first failure. Typing is a hint:
* a refused one (rate limited, 429) never stops the reply. */
async run(conversationId: number, actions: Action[]): Promise<void> {
for (const a of actions) {
if (a.kind === 'typing') await this.typing(conversationId).catch(() => undefined);
else if (a.kind === 'reply') await this.reply(conversationId, a.content);
else if (a.kind === 'handoff') await this.handoff(conversationId, a.reason);
}
}
}// Sample Kommio agent bot: greets new conversations, echoes customer
// messages and hands off to a person when asked. No dependencies; runs on
// Node 22 with `node --experimental-strip-types server.ts`.
//
// KOMMIO_BOT_TOKEN=<bot token> KOMMIO_BOT_SECRET=<signing secret> PORT=4568 \
// node --experimental-strip-types examples/bots/echo-ts/server.ts
//
// Kommio POSTs each event to this server's `/`. We verify the signature,
// answer 200 right away and do the work afterwards, so a slow callback never
// makes Kommio retry the delivery.
import { createServer, type IncomingMessage } from 'node:http';
import { decide, Kommio, verifySignature, type Payload } from './bot.ts';
const port = Number(process.env.PORT || 4568);
const token = process.env.KOMMIO_BOT_TOKEN || '';
const secret = process.env.KOMMIO_BOT_SECRET || '';
const MAX_BODY = 1024 * 1024;
if (!token || !secret) console.warn('echo-bot: KOMMIO_BOT_TOKEN and KOMMIO_BOT_SECRET should both be set');
function readBody(req: IncomingMessage): Promise<string | null> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
req.on('data', (c: Buffer) => {
size += c.length;
if (size > MAX_BODY) { resolve(null); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
async function handle(p: Payload): Promise<void> {
const plan = decide(p);
if (plan.length === 0) return;
await new Kommio(p.callback_api, token).run(p.conversation.id, plan);
console.log(`echo-bot: event ${p.event_id} ${p.event} conv ${p.conversation.id} -> ${plan.map((a) => a.kind).join(',')}`);
}
createServer(async (req, res) => {
const path = (req.url || '/').split('?')[0];
if (req.method === 'GET' && path === '/healthz') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); return; }
if (req.method !== 'POST' || path !== '/') { res.writeHead(404); res.end(); return; }
const body = await readBody(req).catch(() => null);
if (body === null) { res.writeHead(413); res.end(); return; }
const sig = req.headers['kommio-signature'];
if (!verifySignature(secret, Array.isArray(sig) ? sig[0] : sig, body, Math.floor(Date.now() / 1000))) {
console.warn('echo-bot: rejected delivery with a bad signature');
res.writeHead(401); res.end(); return;
}
let payload: Payload;
try { payload = JSON.parse(body) as Payload; } catch { res.writeHead(400); res.end(); return; }
// Ack first, work later.
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"ok":true}');
handle(payload).catch((err: unknown) => console.error(`echo-bot: event ${payload.event_id} failed:`, err instanceof Error ? err.message : err));
}).listen(port, () => console.log(`echo-bot listening on http://127.0.0.1:${port}`));Retries
If a delivery fails because of a non-2xx answer, a timeout or a connection error, Kommio retries it a few times over about 15 minutes. Every attempt is listed on the bot's page (Settings › Marketplace › Installed › Agent bots) under Deliveries, where you can also replay it. Answer 2xx quickly and do the work afterwards, as server.ts does.
Test and type-check
cd examples/bots/echo-ts
node --experimental-strip-types --test bot.test.ts # or: npm testWhich should I use?
An extension is one thing you add to Kommio from Settings › Marketplace; what it can do is one or more of these.
- Sidebar panel Your own page beside the conversation, such as recent orders or a CRM record. Orders panel · reference
- Message hook Check, rewrite or block a reply before it is sent, or an incoming message before it is stored. Orders panel · reference
- Event subscription Signed events at your URL when conversations, messages or contacts change. Webhook receiver · reference
- Agent bot Your bot answers new conversations first and hands off to a person. This page · reference
- Remote data lookup Fetch fields such as balance or VIP tier from your API into the contact and a sidebar panel; set up from the Marketplace, no code. Marketplace · reference
- Automation action A rule or macro step that calls your system, such as sending a webhook. Send a webhook · reference
- Rule or flow template Start an automation rule or a chatbot flow from a ready-made template in the Marketplace. Marketplace · reference
Your scripts and back office call Kommio with an API key you scope: see the API sync example. API sync example
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].