Signals and the omnibar
Signals
A Signal is the desktop’s only way of interrupting you, and it’s designed to interrupt quietly.
{ id, beingId, kind: 'decision' | 'insight' | 'reminder' | 'alert' | 'question' | 'celebration', title: string, body: string, options: Array<{ id: string, label: string, hint?: string }>, answer: string | null, priority: 'low' | 'normal' | 'high' | 'urgent', status: 'open' | 'answered' | 'dismissed' | 'expired', source: string, // 'twin' | 'studio:<projectId>' | 'email' | 'muse' | 'system' | 'hush' | 'calendar' action: Record<string, unknown> | null, // what to do when answered dueAt: ISODate | null, createdAt: ISODate,}Anywhere in the API can raise one with raiseSignal(beingId, input)
(apps/api/src/modules/desktop/routes.ts) — the budget guard does this when a spend ceiling is hit
(see Models and the budget guards), the Studio does it when
QA needs a human call, and the learning loop does it for things like “reply needed” on an email
thread. Raising a signal inserts the row and publishes signal.created over the WebSocket in the
same call — the Signals widget and the field ripple react to that event, not to polling.
GET /api/desktop/signals?status=open&limit=50 → Signal[] (sorted urgent → low, newest first within a tier)POST /api/desktop/signals → Signal (any module can raise one)POST /api/desktop/signals/:id/answer → Signal { optionId? , answer? }POST /api/desktop/signals/:id/dismiss → SignalPOST /api/desktop/signals/:id/fewer-like-this → { ok: true }Answering with optionId resolves it against the signal’s own options list and records that
option’s id as answer; answering with free-text answer is also accepted for signals that don’t
offer options. Either way the signal moves to status: 'answered' and publishes signal.updated.
fewer-like-this dismisses the signal exactly like the route above, but also writes a real
preference memory tagged to that signal’s topic — the difference between clearing one card and
actually asking for less of that kind of interruption going forward. Both the daily ceiling on
proactive signals and this feedback loop are documented in full in Calendar, to-dos, and the
Hush.
The attention model in practice
- Decisions render as elegant option cards you answer with one tap — never a blocking modal.
- Insights fade in when relevant and fade out once you’ve seen them.
- Reminders, alerts, and questions behave the same way at different urgencies; only
priority: 'urgent'visually insists. - Celebrations are the one purely positive kind — the town square and citizen life events use this to mark something good without it reading as a task.
- The field itself ripples softly when a signal with normal-or-higher priority arrives — a visual echo, not a sound or a badge. This is Article IX of the constitution made literal: the world may offer, never demand.
A concrete signal
The budget guard raises exactly this shape when the daily ceiling is hit
(apps/api/src/core/budget.ts):
{ "kind": "decision", "title": "Daily LLM budget reached", "body": "Today's $18.00 budget is spent. New model calls are paused until it resets at midnight, or you can raise the ceiling in Settings.", "options": [{ "id": "settings", "label": "Open Settings" }], "priority": "high", "source": "system", "action": { "navigate": "/settings", "reason": "llm_budget", "scope": "daily" }}The Path
⌘K (or Ctrl+K on non-Mac) toggles it from anywhere inside the app shell; / opens it too,
as long as you aren’t already typing in a field (apps/web/src/shell/Shell.tsx). Its own comment
in apps/web/src/shell/Omnibar.tsx calls it the Path: “one bar for every place, generator,
being, thread and thought.” A first character picks a mode, and — six of the seven — resolve
entirely on the client, against data already in the query cache, with no model call at all:
| Prefix | Mode | Resolves against |
|---|---|---|
/ | Place | A fixed table of seven places (world, muse, twin, studio, market, beings, settings) plus their world-bible aliases |
> | Command | A fixed list: shortcuts, add/hide widget, arrange, reset layout, wrap the day, export, sign out |
! | Generator | Your real generators (useGenerators()), fuzzy-matched by name |
? | Search | Your Twin’s memories (useMemories()), plus a link to open the full search in the Twin |
@ | Being | Every being (useBeings()), fuzzy-matched by handle or display name |
# | Thread | Your threads (useThreads()), fuzzy-matched by title |
+ | Note (capture) | Nothing to resolve — stores what you typed straight into memory |
| (nothing) | Ask | The one mode that reaches the network — see below |
A leading space always means Ask, so a question that happens to start with a prefix character
(" / or > , what happens here?") can still reach the Muse instead of being read as a place or a
command. Tab completes the top match; ↑/↓ move through results; ↵ resolves the highlighted
row. This whole grammar, and the shortcut sheet (? in the global shortcuts
table) that lists it, live in the same file so they cannot
drift apart.
Ask: the one mode that calls the backend
Only a bare, unprefixed query (or one you typed with a leading space) reaches
POST /api/desktop/omni → OmniResponse { intent, reply, action }. That route still runs its own
five-way classification for whatever text it receives:
| Intent | Trigger | What happens |
|---|---|---|
navigate | “world”, “go to market”, “/settings” | Resolves against a fixed place table and returns { navigate: <path> } |
generate | “make…”, “write…”, “build…”, “draft…”, “scaffold…” | Calls the real POST /api/studio/projects route internally (via app.inject) and starts a Studio project from your text as the brief |
search | “find…”, “search…”, “look up…”, “what’s the latest…” | Runs the web.search skill, then (LLM budget allowing) asks the cheap tier for a two-or-three-sentence digest with [n] citations |
command | “dismiss signals”, “remember that …”, “switch theme aurora”, “sign out” | Executed directly against your data — no LLM call at all |
ask (default) | anything else, including plain questions | A short, memory-aware answer from the cheap tier, styled in your Profile’s tone |
Classification tries free heuristics first — regexes for navigation, an imperative-verb prefix for
generate, a “find/search/look up” prefix or a present-tense question for search, a fixed set of
verbs for command — and only falls through to one cheap-tier LLM call
(classifyWithModel in apps/api/src/modules/desktop/omni.ts) when none of those match and the
budget allows it. Without a configured OpenRouter key, or with the budget exhausted, ambiguous
queries default to ask and the reply politely says the model isn’t reachable rather than hanging.
In practice, this whole classifier now only ever sees what you typed with no prefix character —
everything covered by the client-side grammar above never reaches it.
A concrete round trip
curl -s -X POST http://localhost:4000/api/desktop/omni \ -H 'content-type: application/json' -b cookies.txt \ -d '{"query":"make a one-page landing site for a coffee roastery"}'{ "intent": "generate", "reply": "On it. \"Coffee Roastery Landing Page\" is planning in the Studio; I will signal you when there is something to see.", "action": { "navigate": "/studio", "projectId": "prj_8f2...", "brief": "make a one-page landing site for a coffee roastery" }}See Briefs for what happens to that brief next.
Undo
Every hotkey on a Signal card that changes something — dismiss, snooze — and a handful of other
destructive actions across the app apply optimistically and defer the irreversible half of the
work: z (or the toast’s own button) puts it back exactly as it was within an 8-second window
(UNDO_MS in apps/web/src/lib/undo.ts), and only once that window closes does the real, permanent
call actually run. The timer lives on window, not inside React, so navigating away doesn’t cancel
a pending commit — closing the tab or the window flushes it instead. One toast, rendered once at
the shell level, is what every undoable action across the Living Desktop shares.