Skip to content

Mobile app

The mobile app is capture-first: a floating “+” button on every screen opens a quick-capture sheet before anything else, because the phone’s job is to get a thought into your graph in under five seconds. From there it grows into a full companion: browse and search the whole knowledge base, ask an agent a question against it, work your task queue, and render any full page through a web view. It talks to the same server as every other client, over tRPC, authenticated with a paired device token.

The app is bottom tabs plus a page stack.

The foreground push-notification feed: a severity dot, red #B4453C for high/ultra, amber #B07C22 for medium, purple #7B5BC4 for low, grey #C9C7C0 for silent, next to a title and sub-line, newest first. Tapping a notification deep-links into the relevant screen or page (see Push, below). This mirrors the severity model from notifications.

A first-class browse/search surface over the whole graph, not a cut-down mobile search box. The search field runs kb.search, the same hybrid search cmd-K uses on the web, capped at 20 results and debounced 250ms; any hit opens as a full page in the page screen’s web view.

With no query, Browse shows Pinned and Recent lists instead, an on-device list (AsyncStorage) of pages you’ve opened from Browse or Ask, newest first. A star toggles a pin; pinned pages sort first and never age out, recents cap at the last 20. Deep link: subspace://browse.

Ask the graph a question through an agent. The prompt invokes the default agent exactly the way ⌘↵ does on the web (agents.invoke {instruction}, the same run and approval plumbing, the same ops.agent_runs / ops.run_steps rows), then the screen polls agents.runTree every two seconds so the answer streams in while the run is still live rather than waiting for it to finish.

The answer shown is the run’s narrative output. Sources chips are built from two things: any [[page]] links inside the answer text, and the pages the run’s kb.read and kb.edit calls actually touched, including child runs. Tapping a source resolves it (via kb.page.resolve, falling back to kb.search) and opens it in the page screen.

Consequential actions stay gated exactly like everywhere else: a pending approval renders as an amber card with Approve & run / Deny (agents.decide), while the rest of the run tree is a read-only, indented step list behind a steps · N toggle. See tools & approvals for what triggers a gate in the first place. Deep link: subspace://ask.

Sections for needs-confirm, up-next, scheduled, and done, sourced from ops.tasks.view. If your queue is locked by a not-now focus rule, the screen shows the lock reason and a reopen countdown instead of cards. Rows resolve through ops.tasks.resolve: Confirm or Dismiss on needs-confirm cards, Done on up-next cards.

Flashcard review surfaces through the task queue today: due spaced-repetition cards for you appear there like any other card, resolved the same Confirm/Dismiss/Done way. A dedicated flip-and-grade review screen is where native FSRS grading will live once it does; until then, the task queue is where review happens from the phone.

The serverUrl this install talks to (persisted in AsyncStorage, http://127.0.0.1:4780 by default in development, a Tailscale address on a real phone), the pending-capture count with a manual “Flush now” button, and the device’s pairing token.

A text field with a route: chip row underneath: inbox by default, plus any #tags already typed, recently-used tags, and a live page search. Typing a #page fragment queries kb.search and tapping a result inserts that page as a tag chip.

Routing itself is server-side: the first #tag in the capture text that resolves to a page names the target inbox via ops.inbox.capture (the same inbox entry point desktop and web use), so the chip row is a hint, not the router.

The mic button (expo-av) records audio, uploads it to POST <serverUrl>/files (multipart, field file), and files the capture as [voice note](file:<id>). Transcription happens server-side after the upload: the app’s job is just to get the audio link filed quickly, the transcript fills in on the page afterward.

Every capture is appended to an on-device FIFO (subspace.captureQueue.v1) before it’s sent anywhere, so a capture is never lost to a bad connection. A flush loop drains that queue on capture, on app foreground, and every 30 seconds, always in ts order, and it stops at the first failure: a thrown send (airplane mode, no signal) halts the drain rather than skipping ahead and reordering captures, and already-sent ids are skipped on retry so a partial flush never double-files. The “+” button carries a badge with the pending count so you can see queued captures at a glance.

Built on expo-notifications: the app requests permission, registers an Expo push token through ops.push.register (stored on the device row), and the server’s push provider delivers the same severity-templated payloads used everywhere else, marked time-sensitive for ultra severity so it can break through Focus modes on iOS. Tapping a notification maps its payload (data.{route, pageSlug}) to a deep link: subspace://tasks for queue and ultra-severity routes, subspace://p/<slug> when a page slug is present, otherwise subspace://today.

A real iOS share-extension target, not a workaround, built by the expo-share-extension config plugin (Expo doesn’t ship one out of the box). Because a share extension runs in its own process with no Tailscale reachability and no app session, it can’t call the server directly: it appends {text?, url?, ts} as a JSON line to an App Group container file. The main app drains that file on its next foreground, filing each line through ops.inbox.capture and then deleting it, so sharing a link or a snippet from any other iOS app queues exactly like an in-app capture.

Rebuilding the outliner in React Native isn’t worth the duplication, so a full knowledge base page renders in a react-native-webview pointed at <serverUrl>/p/<slug>, the same SPA every other client runs, inheriting its editing, elements, and rich-text behavior unchanged.

The app is deliberately self-contained: a hand-rolled tRPC 11 client (no transformer) against <serverUrl>/trpc, carrying x-subspace-device-token on every request.

Procedure / route Used by
ops.inbox.capture Quick capture, offline flush, share-sheet drain
ops.tasks.view / ops.tasks.resolve Task queue
ops.push.register Push registration
kb.search Browse, #page routing in capture
kb.page.resolve Resolving an Ask source citation
agents.invoke / agents.runTree / agents.decide Ask
POST <serverUrl>/files Voice-note upload
<serverUrl>/p/<slug> Full page rendering