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
| Member | What it does | Permission |
|---|---|---|
registerCommand(name, handler) | adds /name; ctx.reply() prints locally | commands |
registerInputHandler(handler) | claims raw composer input before mention routing; resolve true = handled | commands |
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 tick | background |
nostr | publish, sign, subscribe, query, NIP-44 encrypt/decrypt, NIP-17 DMs | per-method, below |
channels | list(), ensure(spec), say(channelId, text) | — |
workspace | relayUrl, owner, the relay's NIP-11 doc verbatim | — |
client | the process's one shared @fezchat/client instance | read:channels |
ui | status bar, side panels, message log, notifications, view bus | — |
storage | per-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,sendDmneedpublishquery,subscribeneedread:channelsunwrapDmneedsread:dmssignEvent(sign without publishing — NIP-98) andencryptneedsign—publishalso satisfies this, since you can't publish an event without signing itdecryptneedssignorread:dmspubkeyis 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.missedWindow — true 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 footercreateSidePanel(opts)→ handle withsetText()appendMessage()/prependMessage()→ handles withsetAuthor,setContent,setFooter(dim line under the bubble),setMetaonLogScrollTop(handler)— load-older trigger; the viewport is held across prependsnotify(text)— a dim system one-linerviewBus— 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;/backreleases any owner andonChangefires so the timeline repaints. CheckviewBus.owner()before writing to the log.
Storage (0.2.0)
api.storage — get/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).