Realm
One workspace for every coding agent, on your Mac.
realm.computer · Download · iPhone app (TestFlight) · Changelog
macOS · Apple silicon · in active development
Every space in the sidebar, a session and the two files its turn edited, and the file its answer named, open beside it at the line it named.
Local-first agent control plane for macOS — profiles → spaces, every space in one sidebar, split panes for sessions beside one side panel for terminals / browsers / devices / documents, a context pool, and an MCP gateway. See docs/superpowers/specs/2026-08-17-realm-v1-design.md.
Architecture
docs/architecture is the map of how Realm is built: every process and the lines between them, every package and who depends on whom, and the stack layer by layer with its versions. features.md follows each feature through it, one drawing each, and realm-architecture.html is the whole system as a poster you can pan and zoom.
Dev
- Node ≥ 22.13, pnpm 10, macOS.
- Hugeicons Pro token in
.npmrc(see.npmrc.example), if you have one; see below if you do not. pnpm install && pnpm dev- Tests:
pnpm test· Types:pnpm typecheck - Data lives in
~/Realm/(override withREALM_HOME).
Building without the Pro icons
The icons come from Hugeicons Pro where there is a token for Hugeicons' registry. Without one there is
nothing to set up: pnpm install skips the Pro pack, which is optional, and the app draws the free
@hugeicons/core-free-icons pack (MIT) under the same names. At the pinned version those are the
same glyphs.
git clone https://github.com/31Carlton7/realm.git && cd realm
pnpm install --frozen-lockfile
pnpm build
The build prints [hugeicons] the Pro pack is not installed; drawing the free pack when it is drawing
the free pack. docs/dev/hugeicons.md has the rest.
Website
site/ is the marketing site and docs (Next.js, deployed on Vercel with Root Directory = site).
It is deliberately outside the pnpm workspace and carries its own lockfile — see site/README.md for
why that matters and how to verify the WebGPU hero shader headlessly.
Agent sessions
- Claude sessions run on
@anthropic-ai/claude-agent-sdk, which drives theclaudeCLI: install it and log in first (claude auth login). An expired login shows up as an error in the transcript.
~/.claude/settings.json (an apiKeyHelper command and/or an env block) survives skills-enabled sessions too: skill isolation runs with settingSources: [], which stops the CLI reading that file, so the adapter carries its auth through the session's environment instead (packages/adapters/src/claude/settings-auth.ts).
- ACP agents — Cursor, Gemini, OpenCode, GitHub Copilot, goose, Qwen Code, Grok and fx all speak the
packages/adapters/src/acp/) and adding another
is an AcpAgentSpec entry in apps/server/src/app.ts. Install and sign in to each out of band; Settings →
Engines probes what is on the machine and shows the exact install or login command for what is not.
Realm never calls ACP's authenticate itself.
- Note that ACP has deprecated modes/models in favour of configOptions, and agents have split:
Cursor still answers with the old shape, OpenCode answers with only the new one, Copilot sends both.
acpSessionConfig (in @realm/contracts) normalizes both and carries the id to write back through —
reading one channel and writing on the other is a silent no-op.
- Offline / UI work:
REALM_ENABLE_FAKE_AGENT=1 pnpm devregisters a scripted Fake agent (echoes what you send) in the model picker. - MCP gateway — third-party MCP servers are configured in a space's settings, not per-agent: every session gets one Realm gateway endpoint, and credentials or OAuth tokens never reach the agent CLI. Every proxied tool call shows up in the Activity view (Connections ▸ Activity, or "MCP Activity" in the command palette).
Command line (realm)
apps/server/bin/realm runs Realm's server and drives it from a shell, with no window. It talks to
the server on a home (--home, else $REALM_HOME, else ~/Realm) through the port and token in
that home's daemon.json, so a running app and a script see the same spaces, worktrees and
terminals: a command started with realm run shows up in the sidebar as a terminal.
realm serve & # the server alone, headless (ready in under a second)
realm repo add ~/code/app # a checkout as a project; idempotent
realm run ~/code/app --worktree fix-login -- claude -p "fix the login bug"
realm ps # every terminal: running, exited N, or killed (signal N)
realm wait --timeout 600 # exit 0 only if every command exited 0
realm logs <terminal>; realm kill <terminal>
realm ship <checkout> --message "Fix the login bug" --pr # commit it all, push the branch, open a PR
realm ship takes the checkout's id, which realm run --json prints as environmentId and
realm worktree list prints first. It stages every change in that checkout, as the Changes pane's
Stage all does, and commits it. --push also pushes the branch, setting its upstream when the remote
has never seen it, and --pr pushes it and opens a pull request with gh. Where gh is missing or
not signed in, it prints the GitHub link that makes the pull request instead. It is the same commit,
push and pull request the Changes pane makes, and it lands in the space's History beside theirs. It
exits 0 only when every step it was asked for happened, so 1 means nothing to commit (and nothing to
push), a push the remote refused, or a pull request it could only link to.
--json makes every command print one JSON document. Every packaged app ships the command at
and runs it on the app's own binary as Node, so it needs no Node install and
no display: Realm.app/Contents/Resources/bin/realm, squashfs-root/resources/bin/realm in an
AppImage extracted with --appimage-extract, /opt/Realm/resources/bin/realm from the deb, and
resources\bin\realm.cmd on Windows. From a checkout it uses node (22.13+) on dist/realm.js after
pnpm --filter @realm/server build. realm run with --worktree puts each command in its own
worktree on realm/, with that worktree's port block in its environment.
A server on another machine
realm serve runs anywhere Node 22.13 runs, Linux included, and keeps agents working with your laptop
closed. On a Linux machine, from a checkout: pnpm install, pnpm --filter @realm/server build, then
link apps/server/bin/realm onto your PATH (on a Mac, link Realm.app/Contents/Resources/server/bin/realm).
It listens on loopback only unless you add --listen. A client on another machine then needs an access
token, made on the server:
# on the server
realm token create laptop # prints the token once; only its hash is kept
realm serve --tailscale # tailnet only: Tailscale Serve's HTTPS in front of 127.0.0.1:7420
on the laptop
realm remote add vps wss://vps.tail1234.ts.net # paste the token when asked
realm --server vps run /home/me/app --worktree fix-login -- claude -p "fix the login bug"
realm --server vps wait --timeout 600; realm --server vps logs <terminal>
Three ways to reach it, from the most to the least recommended:
- Tailscale Serve:
realm serve --tailscale [--tailscale-port 443]listens on127.0.0.1:7420and
tailscale serve --bg --https=443 http://127.0.0.1:7420, so only your tailnet can reach it,
over HTTPS with the node's own certificate. It is taken down again when the server stops.
- An SSH tunnel:
realm serve --listen 127.0.0.1:7420on the server,ssh -N -L 7420:127.0.0.1:7420
realm --server ws://127.0.0.1:7420 ….
- TLS on an open port:
realm serve --listen 0.0.0.0:7420 --tls-cert cert.pem --tls-key key.pem
--ca cert.pem.
What it refuses:
- Listening on every address (
0.0.0.0,::) or a public address without TLS. No flag allows it. - Listening on a private address (a LAN, a tailnet IP) without TLS, unless you pass
--allow-plaintext to say the network encrypts on its own.
- On the client, sending a token over
ws://to anything but this machine, without--allow-plaintext. - Any handshake without a valid access token, including the server's own
daemon.jsontoken, and any
Origin header).
- An address after ten refused handshakes in a minute: it is refused for a minute, valid token or not.
realm token list and realm token revoke
manage tokens on the server; a revoked token's open connections close within a few seconds. Tokens can
only be made on the server itself, by someone who can read its home, so a stolen token cannot mint a
successor. A token holder can do anything you can do in Realm on that machine, which includes running
commands as you: treat it like an SSH key, and after a leak, revoke it and check realm phones as well.
It cannot register as the server's own app or claim to be sitting at the machine.
Saved remotes live in ~/.config/realm/remotes.json (mode 0600; a file other users can read is
refused). --server also takes a URL with --token, or $REALM_SERVER and $REALM_TOKEN. With
--server, paths are the server's and must be absolute. Agent CLIs run on the server, so sign them in
there once, over SSH.
Pairing an iPhone with a server that has no window
realm pair # draws the pairing code in the terminal, then asks before pairing
realm phones; realm phones remove <id|name>
realm pair works on the server or with --server. A server started by realm serve has no Realm app
to unlock the Keychain, so it keeps its phone identity sealed with its own key, in
(0600), or in the file $REALM_REMOTE_KEY_FILE names, such as a systemd credential. Anyone who can
read that home can read the key. The app opens what a headless server paired. A headless server never
replaces an identity the app sealed with the Keychain: it says to open Realm on that Mac once.
Running it as a service
realm service install --tailscale # or any realm serve options; status, uninstall
On Linux this writes ~/.config/systemd/user/realm.service and enables it. Run loginctl enable-linger
$USER to keep it running when you are logged out. On macOS it writes a launchd agent,
~/Library/LaunchAgents/co.charmtechnologies.realm.server.plist, which logs to .
The file runs the exact Node and realm.js you installed it from, so install again after moving either.
Code graphs (Graphify)
Graphify extracts a queryable knowledge graph from a checkout. It is a Python CLI, installed out of band like the agent CLIs — Realm probes for it and never installs it.
- Install it with the
mcpextra:uv tool install "graphifyy[mcp]". A plain
uv tool install graphifyy still puts a graphify-mcp on PATH, but it dies on
ModuleNotFoundError: No module named 'mcp' the first time the hub dials it.
graphify.probe/graphify.updateare Realm's own RPC methods, not agent kinds — graphify
AgentKind. graphify update runs
graphify update . in the space's primary checkout and needs no LLM and no API key.
- As an MCP server, add it in a space's Connections: transport
stdio, commandgraphify-mcp,
graphify-out/graph.json. Its ten graph tools then reach every session
in that space through the one Realm gateway endpoint.
- The rendered
graphify-out/graph.htmlopens in the documents pane. graphify points its only
default-src 'none', so the preview server
rewrites that tag to a vendored vis-network and drops the hash, which no longer describes the
bytes being served (localizeVisNetwork in apps/server/src/documents/preview.ts).
- Proof:
pnpm --filter @realm/server exec tsx scripts/live-graphify-check.tsdrives the real CLI,
Saved sign-ins (browser panes)
An agent driving a browser pane can sign you in to a site without ever seeing the password.
- You enroll your own passwords in Settings → Sign-ins. That is the only path for a value you
- An agent can ask Realm to GENERATE one, with
browser_fill_credential'sgenerateargument
credentialId: Realm mints a random password in the main process, saves it to the same
store, and fills it under the same three gates. The agent never receives the value and cannot read
it back, so this is the way to set a password on a sign-up form — strictly better for secret hygiene
than an agent printing one into the transcript for you to copy. The origin is not the agent's to
choose: it is read off the pane, shown on the card, and checked again against the live page before
anything is typed. What it costs you is stated on that card — nothing can read the value back
afterwards, so the site's own reset is the way in if you ever need the password outside Realm. Those
rows are marked "Generated by Realm" in Settings, and the audit log records them as generated.
- Values live in the OS Keychain, encrypted by Electron's
safeStorage
apps/desktop/src/main/secret-store.ts), decrypted only inside Realm's main process, and never
readable back — not by you, not over IPC, not through the gateway. MCP OAuth tokens share the same
store, so realm.db no longer holds them in the clear. (Per-server MCP env/headers still do;
MCP_SECRET_STORAGE_NOTE says so.)
- Every fill is gated three ways: the pane's current origin, read from CDP, must exactly equal the
bypassPermissions, is never batched, and answering "always"
licenses nothing. Fills are logged to ~/Realm/logs/credential-audit.log — timestamp, origin,
credential id, outcome, never the value.
- Typing into a password field with
browser_actis still refused, in every mode.
browser_fill_credential is a separate op, not a way around it.
- Two-factor is not automated, and will not be. A Duo/Okta push cannot be driven from here, and a
- A Mac without a Touch ID sensor can save sign-ins but cannot fill them:
promptTouchIDis
Packaging
pnpm dist— full build + DMG and zip inapps/desktop/release/(pnpm dist:dirstops at an
Realm.app for fast iteration; pnpm app:update runs it and swaps the result into
/Applications/Realm.app, quitting a running copy first). Under the hood: root pnpm build, then
apps/desktop/scripts/stage-pack.mjs stages .pack-stage/ (a pnpm deploy of realm-server with
its production node_modules, the bundled skills/, the ScrollPhase helper, the icon), then
electron-builder (apps/desktop/electron-builder.yml) packs it — server and skills as real files
under Contents/Resources/, never inside the asar, because node-pty's native prebuilds and a
spawnable server entry can't load from an archive.
pnpm app:update— the fast local update loop: builds the unpacked app, gracefully quits the
/Applications/Realm.app, replaces it with rollback protection, and relaunches it.
Set REALM_APP_PATH=/another/location/Realm.app to target a nonstandard install. This is for
locally built unsigned copies; published builds continue to use the signed updater below.
- No system Node needed: the packaged app runs realm-server under its own binary with
ELECTRON_RUN_AS_NODE. Launched from Finder (launchd's minimal PATH), main adopts the login
shell's PATH at startup (login-shell-path.ts) before anything spawns, so agent CLIs and
mac resolve; if the login shell can't be asked (exotic shell, timeout), it falls back to the
inherited PATH plus /opt/homebrew/bin:/usr/local/bin. Terminals spawn login shells (-l).
- Proof:
node apps/desktop/scripts/packaged-smoke.cjslaunches the packaged binary with a
REALM_HOME and a stripped PATH=/usr/bin:/bin and asserts boot, agents.probe
finding claude, bundled skills, and a terminal resolving claude.
- Unsigned by default: with no signing credentials in the env,
pnpm distbuilds an unsigned,
scripts/pack.mjs passes -c.mac.identity=null and says so). A copy
downloaded to another Mac will be quarantined: first launch needs right-click → Open, and on
Apple Silicon Gatekeeper may report the app "damaged" — clear it with
xattr -cr /Applications/Realm.app. Locally built copies launch normally. Signing and
notarization are fully wired and env-activated (CSC_LINK/CSC_KEY_PASSWORD +
APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID): with those set, the same pnpm dist
signs, notarizes and staples with zero code changes — exact steps in docs/dev/signing.md.
Releasing
pnpm release— bumps the app version (patch;--minor/--majorfor more), prepends a
CHANGELOG.md from merged PR titles since the last tag (via gh when it
answers, else git log's squash-merge subjects; offline it degrades to plain commit subjects),
builds the dmg + zip + latest-mac.yml through the normal pnpm dist, commits, and creates the
vX.Y.Z tag locally. It never pushes and never publishes — it ends by printing the exact
manual next steps (review the stub, push branch + tag, gh release create with the artifacts).
--dry-run shows the whole plan without touching anything.
Updates
- Signed packaged builds check the public GitHub release feed on launch, download a newer version
- Public releases must carry the dmg, zip, and
latest-mac.ymlartifacts produced bypnpm release.
- The hard gate in
apps/desktop/src/main/updater.tsstill disables updates in development and in
docs/dev/signing.md; pnpm app:update handles local unsigned builds.
Importing from the agent CLIs
Settings → Import brings what Claude Code, Codex and Cursor already have on disk into Realm: transcripts, the Claude memory tool's per-project fact folders, and user-level skills.
- The agents' directories are read-only.
~/.claude,~/.codex,~/.cursor,~/.agentsand
~/.gemini are copied from — never written, moved or cleaned up. Everything the import produces
lands in Realm's database or under ~/Realm/.
import.scanwrites nothing. It opens files, matches candidates to spaces and answers; no
import.apply writes, and only for the
keys it is handed — so the preview you approve is the work that happens.
- Space matching is most-specific-location-wins (
apps/server/src/import/match.ts): walking the
MATCH_MAX_HOPS) because one broadly-registered
ancestor would otherwise capture every session on the machine. Anything unmatched falls to a
profile's Imported space, and every row shows the rule that placed it so a wrong guess is visible.
- Imported sessions keep their provider id when the recorded cwd still exists, so sending a
sessions.moveToSpace refuses
once a session has events, and an imported session has a transcript from the moment it exists.
- Memory is not flattened into the space doc. Fact files are copied to
~/Realm/memory/imported/// and the index goes into the space's memory document
between markers (replaced on re-import, never duplicated). The
largest folder here was 712k characters against a 100k doc cap; inlining would have dropped most of
it and called that an import.
- Skills are copied, never symlinked or overwritten, and land unscoped — visible in every space,
One transcript is not one file. Codex rewrites a whole thread into a new rollout file every time it is resumed — on one machine here, 241 files were 71 conversations, with 158 of them replays of a single Stora thread. The scan keeps the fullest copy of each and counts the rest, so the panel offers conversations rather than files.
Two scripts, from apps/server:
tsx scripts/live-import-check.ts— prints what an import would do against this machine's real CLI
VACUUM INTO copy of the database so it can reason over your actual spaces
without being able to write to them.
tsx scripts/undo-import.ts --all-imported --sweep-environments— takes an import back out. Only
import are touched, and the agents' own directories are never read
or written. Worth knowing about before a big import: an imported session cannot be re-targeted
afterwards, and the environment rows an import leaves behind will out-match everything on the next
run if they are not swept with it.
Screens
Real captures of a real space, taken against the built app by site/scripts/capture-product.mjs,
then framed as the site frames them and laid on an opaque ground by site/scripts/capture-brand.mjs —
the window is made of translucent material, so a capture laid straight onto GitHub's white theme
washes its sidebar out.
Every space, in one sidebar. Each space is a section of one list, and Needs you heads it with every session waiting on a permission or a question, from every space and every profile.
Two questions waiting on you, one from another profile, above every space's sessions.
Bring the agent you already use. Claude Code, Codex, Cursor, Gemini, OpenCode, GitHub Copilot, goose, Qwen Code and Grok all run here, each keeping its own login, models and permission modes.
Every installed agent's models in one list: Opus 5.5 picked, at Max, with fast mode on.
Your tools, without your credentials. Connections belong to the space rather than to an agent, so a session is handed the tools and never the token — it calls Realm, and Realm calls the server.
Connecting an app to a space. One click each, and every session in the space can use them.
Confine what an agent can touch. A space can put its agents and terminals behind a macOS Seatbelt policy. It ships off, per space — Seatbelt is not a container, and the network stays open.
The three postures a space can take, on the one it ships with.
Skills
skills/ holds skills Realm ships, one folder per skill, laid out exactly like the library at
~/Realm/skills/ (spec §7) so installing one is a copy. SkillSync — per-profile enablement and
the symlink into each session's .claude/skills/ — is not built yet, so until it is, enable a
bundled skill by hand:
ln -s "$PWD/skills/mac" ~/.claude/skills/mac
study-guide/lecture-notes— the school workflow (Plan 22): how to write a self-contained
realm-docs gateway tools (docs_search, docs_list, docs_open, docs_progress), and the
palette's New lecture… / Wrap up a lecture… / Import recording from Plynn… entries drive the loop.
mac— the mac-cli binary: Calendar, Reminders, Contacts, Mail,
mac is already on a session's PATH whenever
it is on the PATH Realm was launched from — the skill exists to make it discoverable, not
reachable. Note that a Realm launched from Finder rather than a terminal inherits launchd's
minimal PATH, which has neither mac nor claude/codex/node on it; that is a packaging
problem for all of them, not a mac-cli one.
License
Realm is released under the MIT License. A few bundled parts (file-type icons, the iOS device runner, the Inter and JetBrains Mono fonts) keep their own licenses; THIRD-PARTY-NOTICES.md lists them.