Orders panel: a sample app
A complete Kommio app in one TypeScript file with no dependencies: it serves its manifest, vetoes replies that contain a forbidden word from a signed before-send hook, and shows an Orders panel beside every conversation that calls back into Kommio with the installation token.
Last updated
What it is
An app is a service you host that Kommio installs from a manifest. This sample is the smallest complete one: a single file for Node 22 (and a Go twin with the same routes) that does the three things an app can do.
- It serves its manifest at
/kommio.json, naming the scopes it needs (conversations:read,contacts:read), a hook and a panel. - Its
message.before_sendhook verifies theKommio-Signatureheader and vetoes an agent reply that contains a forbidden word, with a reason the agent sees in the composer. - Its Orders panel opens beside every conversation. The page sends the signed
kommio_contextto its own server, which verifies it and reads the conversation through Kommio's API with the installation token. The token never reaches the browser.
When to use it
Copy this when you want something inside the agent screen: a panel showing the customer's orders, subscription or tickets from your own system, or a check on every outgoing reply (a leaked secret, a missing disclaimer, a tone rule) that a person sees before the message leaves. If you only need to be told when something happens, a webhook receiver is simpler; if you only need to write data into Kommio, an API key is enough.
Set up
Start the app
It listens on
PORT(default 4567) and advertisesPUBLIC_URLin its manifest (defaulthttp://127.0.0.1:$PORT).PORT=4567 node --experimental-strip-types server.ts # or the Go twin cd go && PORT=4567 go run main.goInstall it in Kommio
Settings › Marketplace › Build → New extension, with the manifest URL
http://127.0.0.1:4567/kommio.json. A development server allows private addresses; in production the app must be reachable over https. Review the two scopes and install.Give it the credentials
Kommio shows the installation token (it starts with
kap_) and signing secret once. Pass them at start asKOMMIO_TOKENandKOMMIO_SECRET, or afterwards:curl -X POST http://127.0.0.1:4567/configure -d '{"token":"<installation token>","secret":"<signing secret>"}'
| Variable | Default | |
|---|---|---|
PORT | 4567 | port to listen on |
PUBLIC_URL | http://127.0.0.1:$PORT | the base URL the manifest advertises |
KOMMIO_API | http://localhost:8090/api | your Kommio API (hooks also carry it as callback_api) |
KOMMIO_TOKEN | the installation token | |
KOMMIO_SECRET | the installation's signing secret | |
FORBIDDEN_WORD | forbidden | what the hook blocks |
The code
The whole app. The regions marked #region are the snippets the Apps page under Settings › Marketplace › Installed shows, and the test file checks them against vectors signed by Kommio's own code, so what you read here is what the server sends.
// Sample Kommio app: an "Orders" panel beside every conversation and a
// before-send hook that blocks replies containing a forbidden word. One
// file, no dependencies; runs on Node 22:
//
// PORT=4567 node --experimental-strip-types examples/apps/orders-panel/server.ts
//
// Install it in Kommio from http://127.0.0.1:4567/kommio.json (development
// allows private addresses), then POST the token and secret Kommio showed
// once to /configure, or set KOMMIO_TOKEN and KOMMIO_SECRET.
//
// The regions marked "#region" are shown as the Node snippets on
// Settings → Apps; server.test.ts checks them against the wire format.
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { pathToFileURL } from 'node:url';
/** The manifest this app serves at /kommio.json, for the host it runs on. */
export function manifest(host: string) {
return {
name: 'orders',
title: 'Orders',
version: '1.0.0',
api_version: 1,
description: 'Shows the customer\'s orders beside the conversation and blocks replies that leak an order secret.',
scopes: ['conversations:read', 'contacts:read'],
hooks: { 'message.before_send': { url: `${host}/hooks/send`, timeout_ms: 3000, required: false } },
panels: [{ key: 'orders', title: 'Orders', url: `${host}/panel`, location: 'conversation' }],
};
}
// #region verify-hook
/**
* Kommio-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
* signed with the app's signing secret. Reject anything older than five
* minutes and compare in constant time.
*/
export function verifyHook(secret: string, header: string | undefined, rawBody: string, nowMs = Date.now()): boolean {
if (!header || !secret) return false;
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=') as [string, string]));
const t = Number(parts.t);
if (!t || Math.abs(nowMs / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return safeEqual(parts.v1 ?? '', expected);
}
function safeEqual(a: string, b: string): boolean {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}
// #endregion verify-hook
// #region respond
/** What Kommio posts for message.before_send (the fields this app reads). */
export type BeforeSend = { hook: 'message.before_send'; app: string; account_id: number; callback_api: string; message: { conversation_id: number; inbox_id: number; content: string; content_type: string } };
/** An empty 2xx or {"action":"allow"} lets the reply go; "veto" blocks it and the agent sees the reason; "rewrite" replaces the content. */
export type HookAnswer = { action: 'allow' } | { action: 'veto'; reason: string } | { action: 'rewrite'; content: string };
export function beforeSend(p: BeforeSend, forbidden: string): HookAnswer {
if (p.message.content.toLowerCase().includes(forbidden.toLowerCase())) {
return { action: 'veto', reason: `Replies must not contain the word "${forbidden}"` };
}
return { action: 'allow' };
}
// #endregion respond
// #region verify-panel
/** The panel context: base64url(JSON) + "." + hex HMAC-SHA256 of the base64url part, valid ten minutes. */
export type PanelContext = { app: string; account_id: number; user_id: number; user_name?: string; conversation_id?: number; contact_id?: number; inbox_id?: number; issued_at: string; expires_at: string };
export function verifyContext(secret: string, ctx: string, nowMs = Date.now()): PanelContext | null {
const [enc, sig] = ctx.split('.');
if (!enc || !sig || !secret) return null;
const expected = createHmac('sha256', secret).update(enc).digest('hex');
if (!safeEqual(sig, expected)) return null;
const payload = JSON.parse(Buffer.from(enc, 'base64url').toString('utf8')) as PanelContext;
return new Date(payload.expires_at).getTime() < nowMs ? null : payload;
}
// #endregion verify-panel
// #region panel-data
/**
* GET /panel/data?context=<signed context>: the panel page (in Kommio's iframe) sends the
* signed context; the server verifies it, calls Kommio with the
* installation token (never given to the page) and answers what to show.
*/
export async function panelData(ctx: PanelContext, api: string, token: string) {
let subject = '';
let contactName = '';
if (ctx.conversation_id) {
const r = await fetch(`${api}/conversations/${ctx.conversation_id}`, { headers: { Authorization: `Bearer ${token}` } });
if (r.ok) {
const c = await r.json() as { subject?: string; contact_name?: string };
subject = c.subject ?? '';
contactName = c.contact_name ?? '';
} else subject = `(API answered ${r.status})`;
}
// Your store's API goes here; the sample answers two fixed orders.
const orders = [{ id: 'ORD-4242', status: 'shipped', items: '2 × Kommio mug' }, { id: 'ORD-4243', status: 'processing', items: '1 × Sticker pack' }];
return { subject, contact_name: contactName, viewer: ctx.user_name ?? '', orders };
}
// #endregion panel-data
const panelHTML = `<!doctype html><html><head><meta charset="utf-8"><title>Orders</title>
<style>body{font:14px/1.5 system-ui,sans-serif;margin:16px;color:#1f2937}h1{font-size:16px;margin:0 0 8px}.o{border:1px solid #e5e7eb;border-radius:8px;padding:8px 10px;margin:6px 0}.m{color:#6b7280}</style></head>
<body><h1>Orders</h1><div id="who" class="m">Loading</div><div id="orders"></div>
<script>
const ctx = new URLSearchParams(location.search).get('kommio_context');
window.parent.postMessage({ kommio: true, type: 'ready' }, '*');
window.addEventListener('message', (ev) => { if (ev.data && ev.data.kommio && ev.data.type === 'context' && !ctx) load(ev.data.context); });
if (ctx) load(ctx);
async function load(c) {
const r = await fetch('/panel/data?context=' + encodeURIComponent(c));
const who = document.getElementById('who'), list = document.getElementById('orders');
if (!r.ok) { who.textContent = 'Could not load: ' + r.status; return; }
const d = await r.json();
who.innerHTML = '<b data-testid="panel-subject">' + esc(d.subject || '(no subject)') + '</b> · ' + esc(d.contact_name || 'customer') + ' · viewed by ' + esc(d.viewer || '');
list.innerHTML = d.orders.map((o) => '<div class="o"><b>' + esc(o.id) + '</b> · ' + esc(o.status) + '<div class="m">' + esc(o.items) + '</div></div>').join('');
}
function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); }
</script></body></html>`;
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => { let s = ''; req.on('data', (c) => { s += c; }); req.on('end', () => resolve(s)); });
}
function json(res: ServerResponse, code: number, body?: unknown) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(body === undefined ? '' : JSON.stringify(body));
}
/** Starts the app; the credentials can come later through POST /configure. */
export function start(port = Number(process.env.PORT || 4567)) {
const host = process.env.PUBLIC_URL || `http://127.0.0.1:${port}`;
const api = process.env.KOMMIO_API || 'http://localhost:8090/api';
const forbidden = process.env.FORBIDDEN_WORD || 'forbidden';
let token = process.env.KOMMIO_TOKEN || '';
let secret = process.env.KOMMIO_SECRET || '';
return createServer(async (req, res) => {
const url = new URL(req.url || '/', host);
if (req.method === 'GET' && url.pathname === '/kommio.json') return json(res, 200, manifest(host));
if (req.method === 'POST' && url.pathname === '/configure') {
const b = JSON.parse((await readBody(req)) || '{}') as { token?: string; secret?: string };
token = b.token || token;
secret = b.secret || secret;
return json(res, 204);
}
if (req.method === 'POST' && url.pathname === '/hooks/send') {
const body = await readBody(req);
if (!verifyHook(secret, req.headers['kommio-signature'] as string | undefined, body)) return json(res, 401, { error: 'bad signature' });
return json(res, 200, beforeSend(JSON.parse(body) as BeforeSend, forbidden));
}
if (req.method === 'GET' && url.pathname === '/panel') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(panelHTML);
}
if (req.method === 'GET' && url.pathname === '/panel/data') {
const ctx = verifyContext(secret, url.searchParams.get('context') || '');
if (!ctx) return json(res, 401, { error: 'bad context' });
return json(res, 200, await panelData(ctx, api, token));
}
res.writeHead(404);
res.end();
}).listen(port, () => console.log(`orders app on ${host}`));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) start();What Kommio sends and expects
| Hook request | POST JSON {hook, app, account_id, occurred_at, callback_api, message: {conversation_id, inbox_id, channel_kind, sender_id, content, content_type}} with Kommio-App-Hook and Kommio-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body"> |
| Hook answer | an empty 2xx or {"action":"allow"}; {"action":"veto","reason":"<what the agent sees>"}; {"action":"rewrite","content":"<the replacement>"} |
| Failure | a timeout (3 s by default, 10 s at most) or a non-2xx is logged and the reply goes out, unless the manifest marks the hook required |
| Panel | an iframe at the panel URL with ?kommio_context=<base64url(JSON)>.<hex HMAC>, also posted with postMessage once the page posts {kommio: true, type: "ready"}; valid ten minutes |
| API | Authorization: Bearer <installation token> (it starts with kap_), limited to the approved scopes |
Verify
- Open any conversation: the Apps card shows Orders, and the panel lists two sample orders with the conversation's subject read through the API.
- Send a reply containing the forbidden word: it is blocked with the app's reason, and Settings › Marketplace › Installed › Apps → Recent hook deliveries shows
vetoed. - Tests, without a running Kommio:
npm install # typescript and @types/node, for the type check only
npm run typecheck && npm test
cd go && go mod init example.com/orders-panel && go test ./... # outside the Kommio repositoryWhich 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. This page · reference
- Message hook Check, rewrite or block a reply before it is sent, or an incoming message before it is stored. This page · 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. Echo bot · 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].