GUI surface
Panels, cards, views, and themes in the desktop app.
Fez renders standard settings, themes and dialogs from data. Custom extension
UI runs in a native child webview inside the app. Select the runtime in the
installed package's fez.guiRuntime; fez.parts.gui points to its JSON or
JavaScript file.
| Runtime | GUI part | Host behavior |
|---|---|---|
declarative | JSON | Fez renders validated settings and theme palettes |
isolated-settings | IIFE bundle | One custom settings panel in a child webview |
isolated-page | IIFE bundle + manifest contributions | A document view with scoped saves and comments |
isolated | IIFE bundle + manifest contributions | Custom navigation, settings, thread, message and profile views in child webviews |
An absent runtime retains the legacy shared-webview loader. Unknown declarations fail visibly; isolated parts never fall back to that loader. Use a desktop version supporting the declaration: older releases may ignore it.
Start with the section for your interface:
- Standard settings and themes
- Custom settings
- Document views
- Custom navigation, threads, messages, and profiles
The contract below describes current source. Test a packaged desktop build and
set fez.minFezVersion to the oldest version you verified before publishing.
For a custom bundle, the child supplies its own React instance. Use h() calls
or JSX with a bound factory:
import type { GuiExtensionApi } from "@fezchat/extension-api/gui";
let h: GuiExtensionApi["React"]["createElement"];
export default function activate(api: GuiExtensionApi) {
h = api.React.createElement;
api.registerMessageDecorator(
(content) => content.startsWith("📊 poll: "),
({ content, msgId, channelId }) => h(PollCard, { content, msgId, channelId }),
);
}Build with esbuild --format=iife --global-name=__fezExt --platform=browser --jsx=transform --jsx-factory=h. api.React carries createElement plus
useState, useEffect, useCallback, useRef, useMemo.
Registration seams
These are the broader GUI API's registration seams. Each isolated runtime
supports only its documented subset; declaring a runtime does not grant all
methods in GuiExtensionApi.
| Method | What it adds | Permission |
|---|---|---|
registerSettingsPanel(name, render, opts?) | a card in Settings → Extensions. The panel is filed under your extension's name regardless of the name argument — one extension can't impersonate another. opts.source links it to a channel source so the rail offers a settings button | ui |
registerAgentProfileSection(label, render) | extra facts in agent reputation views. render({ pubkey, persona? }, host?) supports elements or mount/dispose. persona is present only for a local agent; never use a foreign display name to select a local account. Sections show their extension source and disappear on unload | ui |
registerGuiCommand(name, run) | a composer slash command; return "" to emit nothing | commands |
registerMessageDecorator(match, render) | a card mounted under matching message bodies (how the poll card enters) | ui |
registerThreadView(name, match, render) | a lens on a whole thread, keyed on the root message's content; rendered above the replies — an index of the conversation, not a substitute | ui |
fez.modelProvider (manifest, not a method) | a model in Agents → model, served by your own bin through the processes broker; the only route from an isolated runtime — see Model providers | ui + processes |
registerPageView(name, match, render) | own a whole document. match returning true adds a toggle; "default" opens your view instead of markdown. Markdown stays one click away — the document is the truth, a view is a lens | ui |
registerBlockRenderer(lang, render, menu?) | own a fenced code block by language tag. Pass menu (label + insert template) or your block type stays undiscoverable | ui |
watchAgent(name) | open the live activity pane for an agent | read:agents |
openThread(channelId, rootId) / openUrl(url) | navigation | ui |
client | a restricted client adapter; absent without the grant | read:channels |
notify / toast | host notifications / in-app feedback; absent without their grants | notifications / ui |
personas | list/read/update/create personas and roster their keys — the power to reprogram every agent on the machine | personas (sensitive) |
secrets | set(key, value) and has(key) — write-only by design. A panel can store a token it just obtained and check one exists; it can never read it back. The value lands in the OS keychain and is read host-side | ui |
Host-only (no published types yet): registerTheme (a CSS-variable pack,
light + dark), registerMarkdownPlugin (a remark plugin, runs after gfm),
registerArtifactViewer (render a typed kind-40300 artifact), parseQuery.
Client contract
Check api.client before using it. Its read methods return snapshots: changing
a returned map or message cannot change the host's cache. The adapter exposes
an explicit set of methods, not the raw FezClient or its wire. New core
methods do not automatically become extension capabilities.
| Operation | Additional grant |
|---|---|
ensureChannel, sendChannelMessage, toggleReaction, publishDoc, publishDocComment, publishArtifact, saveExtensionConfig | publish |
httpAuthHeader | sign or publish, matching the headless signing rule |
decryptFrom | sign or read:dms |
extensionConfig | sign |
agents, workingAgents, runQuery with source runs | read:agents |
Denied synchronous calls throw; denied asynchronous calls reject. Errors name
the extension, operation, and missing grant. Handle these errors in buttons and
effects. An empty result does not stand in for denied access. on supports
channelsChanged and paymentReceipt; other events are rejected.
Existing installations keep their recorded grants. If an extension adds a required permission, reinstall it to review and grant that request.
Page and block props
registerPageView's render receives { content, save, comment, title, channelId, slug?, versionId?, editable }. save(next) is the whole write path — it
publishes a new version; your view never learns whether it's a wiki page or
a channel doc. comment(text, anchor, mentions) anchors a thread to a line —
mention an agent in it and the agent answers inside the document. editable
is false when an old version is on screen or the extension lacks publish.
Both callbacks reject without publish, even if a view ignores editable.
Block renderers receive { info, body, raw, channelId, slug? } — info is
everything after the language tag on the fence (agent=researcher refresh=daily), and raw is the verbatim block, the anchor for comments.
Network access
Settings and page children block direct fetch, WebSocket and media requests
through CSP. Use api.fetch for their bounded native HTTPS transport and
api.openUrl for browser links. These require recorded hostname grants;
exact hosts, .example.com subdomains and * are recognized.
network:relay is not expanded by this transport.
Custom isolated children additionally allow direct HTTPS/WSS connections,
images and media to their recorded network: hosts. A leading dot covers the
domain and its subdomains; network:relay permits the configured relay origins
and their corresponding HTTP(S) origins. The native host constructs this CSP
for each child. A changed grant closes the custom view on its next broker
check, including the periodic snapshot check; reopen it to use the new grants.
This isolates native authority and browser storage, but does not contain all network traffic: the macOS probe found that WebRTC can still send STUN traffic. Separate webviews also do not guarantee separate OS processes. Legacy GUI parts still share the main webview; their shadowed network APIs are only checks on supplied APIs, not a security boundary.
The processes grant remains broad authority to run the package's own
executables. The native broker binds an isolated caller to its installed
package and rejects other packages' binaries; it does not sandbox that
executable's filesystem, keychain, network or spending authority. Wallet's
existing setup intentionally returns a newly created wallet mnemonic to its
view for backup. Its own CLI can also export remote hotkeys. These capabilities
are distinct from Fez's native custody of the Nostr identity key.
Declarative settings and themes
Declare "guiRuntime": "declarative" and
"parts": { "gui": "dist/gui.json" }. Fez validates the entire bounded JSON
payload before registering anything; extension JavaScript and CSS are not
evaluated in the main window.
The JSON can contain settings and themes:
- A
processsection declares an owned binary, bounded status arguments, labeled phases, and fixed run/spawn actions. It requiresuiandprocesses. Browser uses it for setup, progress and testing. - An
agent-selectsection declares an own preference key, options, stable identity-based defaults and optional HTTPS preview URLs. It requiresuiandread:agents; previews also require their recorded network grant. ElevenLabs uses it for voice selection and previews, preserving overrides. themesmaps names tolightanddarkpalettes. Only known Fez tokens and validated color values are accepted; fonts and layout remain fixed. The Themes package supplies these palettes for Appearance settings.
The source schemas are declarative-gui.ts and the shipped Browser,
ElevenLabs and Themes JSON files. Current ElevenLabs permissions include
read:agents, network:api.elevenlabs.io, network:storage.googleapis.com,
network:relay, publish, read:channels and ui. Its older installed
manifest and recorded grants may lack read:agents; updating a manifest alone
does not grant it.
Model providers (manifest)
An extension that runs a model can add it to Agents → edit agent → model
without any code in the main page — which is what makes this work from the
isolated runtimes. Declare the provider in package.json and ship the bin
that answers for it:
{
"fez": {
"type": "extension",
"parts": { "gui": "dist/gui.js" },
"guiRuntime": "isolated",
"permissions": ["ui", "processes"],
"bin": { "fez-mesh": "dist/cli.mjs" },
"modelProvider": {
"id": "mini",
"label": "Shared Models",
"bin": "fez-mesh",
"list": ["models", "--json"],
"prepare": ["prepare", "--name", "{persona}", "--model", "{model}"]
}
}
}The desktop registers it as ext-<extension>-<id> (ext-mesh-mini above) and
drives it through the processes broker, so the same processes grant and bin
claim that gate api.processes.run gate this. Both ui and processes must
be recorded grants; a missing grant or a malformed declaration shows in the
extension's status and does not stop its GUI part from loading.
listruns the bin with those args and must print a JSON array of{ id, label, status, detail? }withstatusone ofready,offline,busy. A nonzero exit reports stderr in the picker.prepareruns when the user saves that model on an agent, with{persona}and{model}substituted into the args (both must appear at least once). Exit nonzero with the reason on stderr to refuse — the message is shown as the save error. Exit zero once the agent may use the model.
api.registerModelProvider remains for parts on the legacy host, but a
manifest declaration works in every runtime and needs no host-page code.
Isolated settings runner (macOS)
Compatible settings-only GUI parts select a separate native webview in their package manifest:
{
"fez": {
"type": "extension",
"parts": { "gui": "dist/gui.js" },
"guiRuntime": "isolated-settings",
"permissions": ["ui"]
}
}GitHub and Slack use this runtime. Install or relink the updated package so its installed manifest includes the declaration, then open its settings normally. The panel opens inline. The main loader registers its embedded host without evaluating the extension bundle or injecting its CSS. The separate entry supports both legacy React elements and the mount/dispose callback; mounting React alone in the main page does not provide this boundary.
Each declared extension gets its own child webview, native session and
preference namespace inside the main window. It resizes with Settings and
closes when its page is left; host dialogs temporarily hide it. Main-page
reloads revoke and close all child panels. All require a recorded ui grant. An absent guiRuntime keeps
the legacy GUI host. Unknown or malformed runtime declarations fail visibly
without evaluating the bundle or injecting its CSS. A declared isolated part
never falls back to execution in the main page if loading fails, including
on platforms where the isolated runner is unsupported.
Use a desktop build that supports this field: older releases ignore unknown
manifest fields. The declaration does not retrofit isolation into an older
host. The previous VITE_FEZ_ISOLATED_PANEL selector has been removed.
Build compatible settings parts against the explicit, type-only contract:
import type { IsolatedPanelApi } from "@fezchat/extension-api/gui";
export default function activate(api: IsolatedPanelApi) {
api.registerSettingsPanel("My settings", (host) => {
if (!host) throw new Error("Mount node required");
host.textContent = "Settings are ready";
return () => { host.replaceChildren(); };
});
}IsolatedPanelApi contains local React, one settings registration, preferences,
write-only secrets, browser links, HTTPS fetch, host details/confirmation
dialogs, and a limited optional client.
Rust binds each request to the actual webview, checks current recorded grants,
and refuses general native commands. Preferences stay in the installed
extension's own file and preserve other state; corrupt files fail closed.
The published contract and GitHub's mirror are compiled against the real host.
The client requires read:channels. Its agents() method is an initial
snapshot requiring read:agents. Config reads require sign; config writes
also require publish. Config operations reuse FezClient in the main window,
including its existing encryption and relay publication. Extensions receive
neither the raw client nor arbitrary signing/decryption operations.
client.listChannels() returns current active channels as copied records
(id, name, optional source and meta). Store the chosen ID as an
integration destination; channel names can change or collide.
client.createChannel(name) creates an ordinary channel and returns its new ID.
It requires publish and workspace ownership, and never adopts or retags an
existing channel with the same name. Both methods also work in the legacy GUI host.
Config and secret namespaces use the installed directory with fez- added
when absent: both github and a locally linked fez-github use fez-github.
If both aliases are installed, config and secret access fail until the
duplicate is removed. An extension cannot request another namespace. Secrets
expose only set and has, use the existing keychain slots, and require ui.
A keychain access error is reported rather than interpreted as disconnected.
GitHub settings
Update the GitHub package and approve its declared permissions, including
network:github.com and network:api.github.com. Existing recorded grants
are not silently expanded by the runner. Its installed manifest selects
"guiRuntime": "isolated-settings"; both github and the locally linked
fez-github directory work without a launch flag.
The GitHub panel keeps device login, repository discovery, watch/triage
settings and GitHub browser links. Each repository attaches to a selected
ordinary channel, or the user can create one. Existing watches migrate using
their exact repository metadata and retain their history. GitHub and ElevenLabs
omit settingsSource, so their panels appear in global Settings. Extensions
that need a dedicated source group can still declare settingsSource in their
manifest; isolated registration options do not override that metadata.
Use api.fetch explicitly, including when configuring HTTP libraries.
The native transport permits UTF-8 GET/HEAD/POST requests only, over HTTPS
port 443 with a matching recorded network: hostname grant. It rejects URL
credentials, non-public resolved addresses, cookies and privileged headers,
redirects, bodies above 64 KiB, and responses above 2 MiB. Browser links use
the same HTTPS/hostname grant check. network:relay is not expanded by this
transport. There are at most 16 in-flight requests per
panel; host replies time out after 30 seconds. HTTP uses a 20-second transport
timeout; system DNS resolution can outlast that timeout.
Main-window operations are delivered by a channel carrying request IDs only; a main-only command returns the authorized operation. Large Tauri channel messages are delivered directly to their bound webview, avoiding Tauri 2's unscoped fetch queue. Replies are withheld after revocation or close. Operations already dispatched may finish; closing a panel does not roll back a write.
This is native privilege isolation, not a complete extension sandbox. CSP blocks direct fetch, WebSocket and media requests in this settings runner; the broker's HTTPS transport is separate. Declarative ElevenLabs previews use Fez's own media control. This runner currently refuses non-macOS hosts: Tauri's large invoke-response path needs separate hardening there. Installed executable parts and other GUI extensions retain their existing execution model. Agent snapshots refresh when reopened; concurrent CLI and GUI preference writes still need a shared cross-process lock. Use the custom runtime below for its supported process and live-view capabilities. Isolation remains selected per installed manifest.
The manual native probe uses temporary state and simulated credentials; it
never starts agents or accesses the user's keychain. Build the desktop, then run
cargo run --example isolated-panel-probe --features tauri/custom-protocol
from the desktop's src-tauri directory. It checks the real bundled webview,
config and secret routing, large JSON/binary channel delivery and privilege
denials. Its success marker is WKWebView probe: PASS. GitHub's browser test
loads the real bundle with simulated GitHub/keychain responses; it does not
connect the user's account. The regular eval gate checks native authorization.
Isolated document views (macOS)
Kanban uses the same child-webview boundary for its whole document view.
Declare "guiRuntime": "isolated-page" and a guiContributions.page in the
installed manifest:
{
"guiRuntime": "isolated-page",
"guiContributions": {
"page": {
"name": "▦ board",
"match": { "fence": "fez:board", "checklistSections": 2 }
}
}
}The matching fence selects the view by default; checklistSections optionally
adds a toggle for documents with that many headings and a task list outside
code fences. The host validates these bounded declarations before registering
anything. Optional messages provide text-only summaries selected by line
prefixes, with the original message in a disclosure. Optional blocks declare
fence disclosures and insert-menu templates. These features never execute
extension JavaScript or CSS in the main webview.
Build the GUI against IsolatedPageApi from @fezchat/extension-api/gui, then
call registerPageView once with the declared name. Render a React element;
the child loads the bundle and its companion gui.css. The host owns matching.
Use the supplied page's save(next) and comment(text, anchor, mentions);
the broker accepts no channel or document target. Both callbacks require a
current editable version and a live publish grant, checked again after
asynchronous version reads and immediately before publication. Closed panels,
stale versions and revoked grants reject. An already published write is not
rolled back by closing its panel.
Page reads require ui and read:channels. Updates refresh the snapshot
without remounting the page, so open forms retain their drafts. Agent discovery
also refreshes with read:agents; unavailable agents must not prevent reading
cards. pkByName returns undefined when unavailable, while agents() rejects
access without its grant. Review schedules use the existing scoped config API.
The runtime's macOS and network limitations above still apply, and executable
extension parts keep their existing execution model.
Build Kanban and the desktop, then run
cargo run --example isolated-page-probe --features tauri/custom-protocol
from src-tauri. This uses temporary fixture documents to verify the real
macOS view, card details, scoped saves/comments, stale-write rejection and
native privilege denials without touching a user's board or agents.
Custom isolated views (packaged macOS builds)
fez create my-extension --gui creates an isolated React navigation view
with matching manifest contributions, bundled React, and only the ui
permission. Follow the dev loop to build and link it.
Use "guiRuntime": "isolated" for custom UI that needs more than the settings
or document contract. The manifest supplies bounded, data-only contributions;
only the child evaluates the IIFE and its styles. For example:
{
"fez": {
"type": "extension",
"parts": { "gui": "dist/gui.js" },
"guiRuntime": "isolated",
"permissions": ["ui", "read:channels"],
"guiContributions": {
"settings": true,
"messages": [{ "label": "Open poll", "match": { "linePrefix": "📊 poll: " } }]
}
}
}Contributions can declare nav entries, channel-bound tabs and summaries,
threads, messages, profiles, and one settings panel. Match rules use
bounded text or base58-token criteria, never executable callbacks or arbitrary
regular expressions. Names identify navigation and thread callbacks; message
and profile array positions identify the corresponding registration order in
the child. See IsolatedCustomContributions in the published manifest types.
Fez renders the surrounding navigation and labeled message/thread/profile
launchers. A selected custom view opens inside the app with its own native
session. Its original registration callbacks run only there. A copied public
channel snapshot supplies the bound message's actual content, author and time,
names, permitted agent information, signed receipts and the current viewer's
reaction timestamps. No raw client, wire, DMs or arbitrary signing is exposed.
Reaction writes require publish and can target only that opened message.
Snapshots refresh without resetting mounted React component state.
The adapter also supports own storage and preferences, owned processes and
agents, permitted persona operations, bounded navigation, feedback, and
channelsChanged/paymentReceipt subscriptions. Unsupported capabilities
fail; this is not the entire legacy GUI API. Bazaar, Mining and Wallet source
manifests declare this runtime. Wallet keeps its existing author checks,
expiry rules, receipt labels and backup ceremony. Its explorer links require
network:taostats.io, network:basescan.org and
network:sepolia.basescan.org, in addition to its existing grants.
The native custom runner currently requires a packaged macOS build. It
deliberately refuses tauri dev: the development server bypasses the
per-webview asset response hook that installs its network policy. Browser
fixtures can exercise the adapter with mocked IPC, but do not establish the
native boundary.
Host details and confirmation dialogs
Isolated settings, page and custom APIs expose showDetails and confirm:
await api.showDetails({ title: "Card details", body: card.details, context: "Backlog" });
const accepted = await api.confirm({ title: "Apply this change?", body: explanation });
if (accepted) await saveChange();Fez opens a transparent child covering the app and renders its standard dialog
with the installed extension's fixed source label. Title, body and context are
plain text; the overlay never loads extension code or styles. Content scrolls
within the viewport. showDetails resolves when opened. confirm waits for
Confirm or Cancel; dismissal cancels. Callers should handle rejected requests
without applying the proposed change. The legacy GuiExtensionApi.confirm
member is optional; check it when supporting older hosts.
Closing, Escape and backdrop clicks close only the dialog and restore its
parent's focus. Parent closure or reload also removes its dialogs. Native
validation limits title to 4 KiB, body to 64 KiB and context to 256 bytes and
checks the caller's recorded ui grant. Kanban uses showDetails for card
text. Wallet awaits confirm before switching x402 payments to Base mainnet,
including the effective per-call and daily limits in the prompt.
Reload behavior
Gui parts reload live when an extension is installed, updated, or removed from the gallery. Headless and relay parts need their host restarted.