Skip to content

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 → Signal
POST /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:

PrefixModeResolves against
/PlaceA fixed table of seven places (world, muse, twin, studio, market, beings, settings) plus their world-bible aliases
>CommandA fixed list: shortcuts, add/hide widget, arrange, reset layout, wrap the day, export, sign out
!GeneratorYour real generators (useGenerators()), fuzzy-matched by name
?SearchYour Twin’s memories (useMemories()), plus a link to open the full search in the Twin
@BeingEvery being (useBeings()), fuzzy-matched by handle or display name
#ThreadYour threads (useThreads()), fuzzy-matched by title
+Note (capture)Nothing to resolve — stores what you typed straight into memory
(nothing)AskThe 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/omniOmniResponse { intent, reply, action }. That route still runs its own five-way classification for whatever text it receives:

IntentTriggerWhat 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 questionsA 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

Terminal window
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.