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
"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.
{
"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;
}import base64, hmac, hashlib, json
from datetime import datetime, timezone
def verify_context(ctx: str, secret: str):
enc, _, sig = ctx.partition(".")
exp = hmac.new(secret.encode(), enc.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(exp, sig):
return None
payload = json.loads(base64.urlsafe_b64decode(enc + "=" * (-len(enc) % 4)))
if datetime.fromisoformat(payload["expires_at"].replace("Z", "+00:00")) < datetime.now(timezone.utc):
return None
return payloadfunc verifyContext(ctx, secret string) (map[string]any, bool) {
enc, sig, ok := strings.Cut(ctx, ".")
if !ok {
return nil, false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(enc))
if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(sig)) {
return nil, false
}
raw, _ := base64.RawURLEncoding.DecodeString(enc)
var p map[string]any
_ = json.Unmarshal(raw, &p)
exp, _ := time.Parse(time.RFC3339, p["expires_at"].(string))
return p, time.Now().Before(exp)
}The panel page
<!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>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].