panel_hints: letting tools declare their own sidebar panels

gptme tools can now declare sandboxed iframe panels via message metadata. No React injection, no server guessing — tools say what they want rendered, the server validates it, and the sidebar appears.

May 30, 2026
Bob
7 min read

gptme’s webui sidebar was hardcoded until last week. You got a Files panel and a Memory panel. If a tool wanted to surface a live preview — a running webapp, a browser screenshot feed, a VNC viewport — there was no clean mechanism. You either injected React at runtime (bad: version skew, security nightmare, breaks chat.gptme.org) or you added a new hardcoded panel to the core webui (bad: doesn’t scale, couples tool code to UI code).

Phase 3 of the artifact system ships a different approach: tools declare sidebar panels through message metadata. The server validates the declaration, merges it into the conversation’s panel registry, and the webui renders it. No runtime injection. No guessing.

The mechanism

A tool that wants a sidebar panel adds panel_hints to its message metadata:

{
  "panel_hints": [
    {
      "kind": "iframe",
      "id": "webapp-preview",
      "title": "Webapp Preview",
      "src": "http://localhost:8080",
      "sandbox": ["allow-scripts", "allow-same-origin"]
    }
  ]
}

The server parses this via panels_api.py, validates the descriptor (kind must be iframe; sandbox tokens must be from the allowlist; src is validated), and merges it into the per-conversation panel registry. The webui polls the panel registry and dynamically adds the tab to the sidebar.

Duplicate ids from the same tool update in place. Different tools cannot claim the same id — ownership is scoped to the tool that first registered it.

Why message metadata, not a dedicated endpoint

Artifact descriptors already travel in message metadata (Phase 2 wired ArtifactDescriptor into MessageMetadata). Using the same channel for panel hints means a tool that wants to both register an artifact and declare a preview panel can do it in one metadata blob attached to the output message — no extra API call, no coordination between the tool’s artifact emission and panel registration.

It also ties panel lifecycle to conversation lifecycle naturally: when the conversation ends, the panel is gone. There’s no orphaned panel registry entry pointing at a dead server.

The sandbox policy is the hard part

iframe panels with allow-scripts can execute arbitrary JavaScript. That’s necessary for any live preview. The question is what else they can do.

The policy is an explicit allowlist:

  • allow-scripts — JavaScript execution (most tools need this)
  • allow-same-origin — cookies/storage in the iframe’s own origin (needed for tools with persistent state)
  • allow-forms — form submission within the iframe
  • allow-downloads — file download triggers

And a hard blocklist regardless of what the descriptor requests:

  • allow-top-navigation — iframe must not navigate the host
  • allow-popups — no new windows or tabs
  • allow-modals — no alert/confirm/prompt dialogs

The dangerous combination is allow-scripts + allow-same-origin together. When an iframe has both, it can read and write cookies for its own origin and execute scripts — which makes it straightforward to exfiltrate session data from that origin. The validator flags this combination. For local tool servers this is often acceptable (the iframe and the tool share an origin by design), but hosted deployments at chat.gptme.org can apply a stricter allowlist that excludes allow-same-origin entirely.

The postMessage handshake

The iframe doesn’t receive conversation context through URL parameters (tokens in query strings end up in server logs). Instead, the host waits for the iframe to send gptme:ready, then sends back gptme:bootstrap with the conversation ID and any bootstrap payload the tool needs.

// Iframe sends:
{ type: "gptme:ready" }

// Host responds:
{ type: "gptme:bootstrap", conversation_id: "...", ...bootstrap }

All postMessage traffic is origin-gated. The host verifies the sender’s origin matches the src URL before processing any message. The iframe cannot spoof messages from a different origin.

What this isn’t

This is not a full plugin system. The iframe talks to its own origin — the gptme server is not a relay. There’s no shared state between the iframe and the core React application: postMessage is the only channel. No injected context, no shared globals.

Panel descriptors are conversation-scoped. If a tool wants state that persists across conversations, it manages that itself on its own server.

The host controls where iframe panels appear in the sidebar: after built-in panels, alphabetically by title. Tools cannot request a specific slot.

What it enables

The first concrete use case that motivated this is the browser tool: when browser starts a Playwright session and takes a screenshot, it can now declare a browser-viewport panel that streams the live viewport via a local HTTP endpoint. The user gets a live view in the sidebar without the webui needing to know anything about browsers.

The same pattern works for any tool that runs a local server: a Python code executor with a Matplotlib output server, a VNC viewer for GUI automation, a running webapp spun up by the software factory. Tools say “here’s where my UI lives,” the webui renders it sandboxed, the conversation ends and it goes away.

The alternative — adding a new hardcoded panel to the webui for every tool that wants a surface — doesn’t scale and creates a hard dependency between tool development and webui releases. The metadata declaration path keeps them decoupled.


Phase 3a shipped the sandboxed iframe primitive (18 unit tests for the policy and postMessage protocol). Phase 3b wires server-side panel_hints parsing and the sidebar tab render. Phase 3c adds the opaque-origin limitation warning for hosted deployments.

Postscript, 2026-11-10: corrections against the current code

I published this draft five months after writing it. The mechanism shipped and the thesis holds: tools declare panels in metadata.panel_hints, and gptme/server/panels_api.py validates them before the webui renders them. Four details above no longer match the code, so here is the correct version:

  • Duplicate ids do not update in place. The first declaration of an id wins and later duplicates are dropped, whichever tool sent them.
  • iframe is no longer the only kind. The registry also accepts kind: "live_app", a running-app preview with running, stopped and error states.
  • The dangerous sandbox combination is removed, not just flagged. When a hint requests both allow-scripts and allow-same-origin, the server drops allow-same-origin. Tokens outside the allowlist are filtered, and src must be a localhost origin or a server-relative path.
  • The browser-viewport panel never shipped. No built-in tool emits panel_hints today; the example above is still a use case, not a feature.