Extend

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_send hook verifies the Kommio-Signature header 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_context to 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

  1. Start the app

    It listens on PORT (default 4567) and advertises PUBLIC_URL in its manifest (default http://127.0.0.1:$PORT).

    PORT=4567 node --experimental-strip-types server.ts
    # or the Go twin
    cd go && PORT=4567 go run main.go
  2. Install 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.

  3. Give it the credentials

    Kommio shows the installation token (it starts with kap_) and signing secret once. Pass them at start as KOMMIO_TOKEN and KOMMIO_SECRET, or afterwards:

    curl -X POST http://127.0.0.1:4567/configure -d '{"token":"<installation token>","secret":"<signing secret>"}'
VariableDefault
PORT4567port to listen on
PUBLIC_URLhttp://127.0.0.1:$PORTthe base URL the manifest advertises
KOMMIO_APIhttp://localhost:8090/apiyour Kommio API (hooks also carry it as callback_api)
KOMMIO_TOKENthe installation token
KOMMIO_SECRETthe installation's signing secret
FORBIDDEN_WORDforbiddenwhat 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.

examples/apps/orders-panel/server.ts
// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 requestPOST 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 answeran empty 2xx or {"action":"allow"}; {"action":"veto","reason":"<what the agent sees>"}; {"action":"rewrite","content":"<the replacement>"}
Failurea 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
Panelan 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
APIAuthorization: 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 repository

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