fez
Extension API

Headless surface

Slash commands, scheduled tasks, and relay access across terminal and desktop hosts.

The headless part is a single ESM bundle in ~/.fez/extensions/. The TUI, optional sentinel, and current source desktop background worker import it and call the default export:

import type { FezExtensionAPI } from "@fezchat/extension-api/headless";

export default function myExtension(api: FezExtensionAPI): void {
  api.registerCommand("hello", (args, ctx) => ctx.reply("hi!"));
}

The TUI and sentinel log load failures and skip the broken file. The desktop worker validates every selected extension before taking ownership; a missing file or load failure refuses that handoff. Bundle fully (no --packages=external): the install directory has no reachable node_modules, so bare imports won't resolve at runtime.

The API

MemberWhat it doesPermission
registerCommand(name, handler)adds /name; ctx.reply() prints locallycommands
registerInputHandler(handler)claims raw composer input before mention routing; resolve true = handledcommands
registerUrlHandler(prefix, handler)claims clicks on OSC-8 hyperlinks by URL prefix (first match wins)ui
registerScheduledTask(name, everyMs, run)periodic job in the desktop worker or optional sentinel; min interval 60 s; runs never overlap; a throw is logged and retried next tickbackground
nostrpublish, sign, subscribe, query, NIP-44 encrypt/decrypt, NIP-17 DMsper-method, below
channelslist(), ensure(spec), say(channelId, text)
workspacerelayUrl, owner, the relay's NIP-11 doc verbatim
clientthe process's one shared @fezchat/client instanceread:channels
uistatus bar, side panels, message log, notifications, view bus
storageper-extension persistent key-value store(0.2.0) none

Host-only additions (not in the published types, available in the TUI): registerHarness (a new agent engine), registerMcpServer, registerTheme (a TUI theme pack), and registerSystemPromptSection (standing instructions added to every agent this host starts — gated by the sensitive system-prompt permission; re-registering an id replaces it, and nobody can replace the whole prompt).

The desktop starts background work with the app; closing its window keeps work running, and explicit Quit stops it. Register tasks during extension load and put their network work inside the task callback: the worker waits for native activation before running tasks or exposing relay operations.

For headless operation, quit the desktop and run fez sentinel. To troubleshoot one extension, use fez sentinel --extensions fez-github (use its installed directory name). The selection can only narrow the enabled background extensions; it does not grant permissions. Stop any existing sentinel before starting another. On macOS, fez sentinel-install --extensions fez-github saves that selection as a login service and restarts it after a crash. Omit --extensions to run all enabled background extensions. Remove the service with fez sentinel-uninstall.

Nostr access

api.nostr is split per method because reading is the channel firehose and publishing signs as the user:

  • publish, sendDm need publish
  • query, subscribe need read:channels
  • unwrapDm needs read:dms
  • signEvent (sign without publishing — NIP-98) and encrypt need signpublish also satisfies this, since you can't publish an event without signing it
  • decrypt needs sign or read:dms
  • pubkey is ungated — it's the public key, safe to hand out

Denied operations throw or reject, naming the extension, operation, and required permission. Promise-returning methods (publish, sendDm, query) reject; synchronous methods throw. Empty query results and an unrecognized DM remain valid results when the read is granted. Channel helpers propagate these errors, so a denied publish cannot report a channel created or a message sent. Terminal submissions show errors in the timeline.

Registration without a grant still warns and skips, and UI calls outside the TUI remain inert so the same extension can load in either background host.

Channels and workspace

api.channels.ensure() returns undefined unless you are the workspace owner — only the owner may sign a channel into being, and publishing an event the relay would refuse helps nobody. Both channels and workspace are live getters: they resolve on every access, so they survive startup ordering and relay switches. On an unclaimed relay (workspace.owner undefined) channels.list() is honestly empty.

Scheduled tasks get their own context: ctx.ownerPubkey (the machine owner's key — the authority the task acts for), ctx.channels, ctx.nostr, and ctx.missedWindowtrue when the clock jumped more than 2× the interval (the machine slept). Catch up once; never replay the backlog.

UI

api.ui degrades to inert no-ops outside the TUI, so the same bundle runs in the desktop worker or sentinel unchanged.

  • setStatus(key, value) — a segment in the persistent footer
  • createSidePanel(opts) → handle with setText()
  • appendMessage() / prependMessage() → handles with setAuthor, setContent, setFooter (dim line under the bubble), setMeta
  • onLogScrollTop(handler) — load-older trigger; the viewport is held across prepends
  • notify(text) — a dim system one-liner
  • viewBus — cross-extension view ownership: exactly one owner at a time, "channel" is the default timeline. Claim with a namespaced string before painting a full-screen view; /back releases any owner and onChange fires so the timeline repaints. Check viewBus.owner() before writing to the log.

Storage (0.2.0)

api.storageget/set/delete/keys over one JSON file per extension at ~/.fez/extension-data/<name>.json. Namespaced by install name, present without any permission (it's the extension's own notes, not something done to the user), serialized writes, corrupt files read as empty rather than crashing, and the namespace is deleted when the package is removed. Values are anything that survives JSON.stringify. Note: the namespace is keyed on the install name, so keep your package directory name equal to the de-scoped npm name (fez install @fezchat/polls installs as polls).

On this page