Extend

Panels beside conversations

A panel is a page of yours rendered next to a conversation, contact or inbox in a sandboxed iframe, with a signed context saying who is looking at what. Verify the context, fetch what you know about the customer, render.

Last updated

Declare

manifest excerpt
"panels": [
  { "key": "orders", "title": "Orders", "url": "https://orders.example/panel", "location": "conversation" }
]

Locations: conversation, contact, inbox. Agents with conversations:read see a button per panel and open it in a 440 px drawer.

The context

When a panel opens, the agent app asks the API for a context, appends it to your URL as ?kommio_context=<token>, and posts it again over postMessage once your page says it is ready. The value is base64url(json) + "." + hex(HMAC-SHA256(secret, base64url)), valid for ten minutes, and only issued for conversations the viewer may see.

decoded context
{
  "app": "orders", "account_id": 12, "user_id": 3, "user_name": "Rea",
  "conversation_id": 4821, "contact_id": 77, "inbox_id": 3,
  "issued_at": "2026-09-22T09:12:00Z", "expires_at": "2026-09-22T09:22:00Z"
}

Verify on your server

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyContext(ctx: string, secret: string) {
  const [enc, sig] = ctx.split(".");
  const exp = createHmac("sha256", secret).update(enc).digest("hex");
  if (!sig || exp.length !== sig.length || !timingSafeEqual(Buffer.from(exp), Buffer.from(sig))) return null;
  const payload = JSON.parse(Buffer.from(enc, "base64url").toString("utf8"));
  return new Date(payload.expires_at) > new Date() ? payload : null;
}

The panel page

panel.html
<!doctype html>
<html><body>
<h1>Orders</h1><div id="out">Loading</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?.kommio && ev.data.type === "context" && !ctx) load(ev.data.context); });
  if (ctx) load(ctx);

  async function load(c) {
    // your server verifies the context, calls Kommio with the installation token, and answers with what to show
    const r = await fetch("/panel/data?context=" + encodeURIComponent(c));
    const d = await r.json();
    document.getElementById("out").innerHTML = d.orders.map((o) => "<p><b>" + o.id + "</b> " + o.status + "</p>").join("");
  }
</script>
</body></html>
server: /panel/data
app.get("/panel/data", async (req, res) => {
  const ctx = verifyContext(String(req.query.context), SECRET);
  if (!ctx) return res.status(401).end();
  const conv = await fetch(KOMMIO_URL + "/conversations/" + ctx.conversation_id, { headers: { Authorization: "Bearer " + TOKEN } }).then((r) => r.json());
  const contact = await fetch(KOMMIO_URL + "/contacts/" + ctx.contact_id, { headers: { Authorization: "Bearer " + TOKEN } }).then((r) => r.json());
  res.json({ subject: conv.subject, orders: await ordersFor(contact.emails?.[0]) });
});

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