Skip to content

Crow's Nest ​

The Crow's Nest (servers/gateway/dashboard/) is a server-rendered web interface for managing a Crow instance. (The code directory is still named dashboard/ for backward compatibility; the user-facing name is "Crow's Nest.") It uses no frontend framework — HTML is generated server-side and served directly by the gateway.

User-facing walkthrough (panels, launcher, day-to-day use): Crow's Nest guide. This page covers internals.

Brand Identity ​

The Crow's Nest uses the Perch-derived light-first palette, defined as CSS custom properties in servers/gateway/dashboard/shared/design-tokens.js.

Token architecture (Track 2, 2026-08-15) ​

servers/gateway/dashboard/shared/design-tokens.js's designTokensCss() is light-first: base values live on a plain :root block, and dark values override inside a single @media (prefers-color-scheme: dark) block — there is no dashboard theme state (no .theme-light/.theme-dark class, no theme/glass/serif option on renderLayout(), no stored dashboard color-mode setting); light vs. dark follows the OS only. The same module exports PERCH_TOKENS, the authoritative copy of the Perch palette, which drives the board's roost strip + session drawer (Track 3). .theme-glass is retired end-to-end (no glass CSS blocks or theme_glass/theme_dashboard_mode settings remain). The public blog, songbook, and knowledge-base pages don't run on this system yet: design-tokens-legacy.js is a frozen snapshot of the pre-rewrite tokens (minus glass) that servers/gateway/routes/blog-public.js, servers/blog/songbook-renderer.js, and bundles/knowledge-base/routes/kb-public.js import instead, so those surfaces keep today's palette until their own adoption wave.

Color Tokens (Light — :root, base) ​

TokenValueUsage
--crow-bg-deep#eef1f3Page background
--crow-bg-surface#ffffffCard/panel backgrounds
--crow-bg-elevated#f5f7f8Raised surfaces, hover states
--crow-border#dde4e8Borders, dividers
--crow-border-strong#94a4aeEmphasized borders
--crow-text-primary#22303aHeadings, body text
--crow-text-secondary#5c6d79Descriptions, labels
--crow-text-tertiary#6b7c88De-emphasized text
--crow-text-muted#8395a1Hints, disabled text
--crow-accent#0e6b62Primary accent
--crow-accent-hover#0b574fHover state for accent
--crow-accent-muted#dceceaSubtle accent backgrounds
--crow-accent-contrast#ffffffText/icon color on top of --crow-accent
--crow-success#1d7048Success states
--crow-error#b04a2bError states
--crow-warning#8f5606Warning states
--crow-info#33688cInformational highlights
--crow-brand-gold#8f5606Active nav highlight, branding

Color Tokens (Dark — @media (prefers-color-scheme: dark)) ​

TokenValueUsage
--crow-bg-deep#131a1fPage background
--crow-bg-surface#1b242bCard/panel backgrounds
--crow-bg-elevated#232e36Raised surfaces, hover states
--crow-border#2a353dBorders, dividers
--crow-border-strong#46565fEmphasized borders
--crow-text-primary#e4ebefHeadings, body text
--crow-text-secondary#8fa0abDescriptions, labels
--crow-text-tertiary#7d8f9aDe-emphasized text
--crow-text-muted#5f707bHints, disabled text
--crow-accent#4fbdb0Primary accent
--crow-accent-hover#6fd0c4Hover state for accent
--crow-accent-muted#16322fSubtle accent backgrounds
--crow-accent-contrast#131a1fText/icon color on top of --crow-accent
--crow-success#2fa36bSuccess states
--crow-error#d1633eError states
--crow-warning#d9a521Warning states
--crow-info#6aa9ccInformational highlights
--crow-brand-gold#d9a521Active nav highlight, branding

Light vs. dark follows the OS only — there is no dashboard theme-mode setting or class toggle (see the Track 2 paragraph above).

Typography ​

  • Headings & body: Inter (sans-serif, weights 400/500/600/700)
  • Code: JetBrains Mono (monospace, weights 400/600)

Both are loaded via FONT_LINKS (servers/gateway/dashboard/shared/layout.js), a single Google Fonts <link> block shared across every page.

Visual Details ​

  • Card depth via layered box-shadow (subtle glow on elevated surfaces)
  • Gold accent (--crow-brand-gold) on the active sidebar navigation item
  • Illustrated empty states with inline crow SVG icons
  • Login page and setup page display a crow hero graphic

Architecture ​

┌────────────────────────────────────────┐
│           Panel Registry               │
│  health │ messages │ memory │ blog    │
│  files │ extensions │ settings         │
│  + third-party panels from ~/.crow/    │
├────────────────────────────────────────┤
│           Layout System                │
│  layout(title, content, options)       │
│  Navigation, footer                    │
├────────────────────────────────────────┤
│           Auth System                  │
│  scrypt hashing, session cookies      │
│  CSRF tokens, account lockout         │
├────────────────────────────────────────┤
│           Network Security             │
│  IP allowlist (LAN, Tailscale)        │
│  403 for disallowed origins            │
├────────────────────────────────────────┤
│           Express Router               │
│  GET/POST /dashboard/*                 │
└────────────────────────────────────────┘

Panel Registry ​

Panels are modular sections of the Crow's Nest. Each panel registers itself with:

js
{
  id: 'messages',          // Unique identifier
  name: 'Messages',        // Display name in navigation
  icon: 'mail',            // Icon identifier
  route: '/dashboard/messages',
  navOrder: 1,             // Position in the navigation bar
  handler: async (req, res, { db, layout }) => {
    // Render panel content
  }
}

Built-in panels live in servers/gateway/dashboard/panels/:

PanelFileRoutePurpose
Crow's Nestpanels/health.js/dashboard/nestApp launcher tiles, CPU, RAM, disk usage, Docker containers, DB metrics
Messagespanels/messages.js/dashboard/messagesView peer messages, threads, read status
Memorypanels/memory.js/dashboard/memoryBrowse, search, and manage persistent memories
Projectspanels/projects.js/dashboard/projectsBrowse project spaces, sources, notes
Blogpanels/blog.js/dashboard/blogManage posts, publish/unpublish, edit
Filespanels/files.js/dashboard/filesBrowse storage, upload, delete, preview
Extensionspanels/extensions.js/dashboard/extensionsBrowse marketplace, install/uninstall add-ons, resource warnings
Skillspanels/skills.js/dashboard/skillsBrowse and edit Crow skills
Settingspanels/settings.js/dashboard/settingsConfiguration, quotas, network rules, contact discovery, sync-conflict recovery
Contactspanels/contacts.js/dashboard/contactsPeer contacts, invites, discovery
Bot Builderpanels/bot-builder.js/dashboard/bot-builderCreate and configure bots (personas, skills, channels)
Bot Boardpanels/bot-board.js/dashboard/bot-boardKanban cards + custom trackers; per-board statuses and fields from board_defs in tasks.db (Track 0), configured via the board's settings drawer. routes/board-defs.js is the single resolver/validator; a board with no def falls back to the builtin four statuses. Dispatch (execute/plan-dispatch) enqueues onto the job rail and never writes the card — "a bot is working this card" is the lock predicate (routes/board-lock.js); the old stage lifecycle and its un-strand machinery are gone (migration 0002-board-defs drops the column and the status CHECK)
Design Systempanels/design-system.js/dashboard/design-systemLiving reference for tokens and components
Onboardingpanels/onboarding.js(hidden)First-run setup wizard
Connectpanels/connect.js/dashboard/connectConnect-a-client wizard + local MCP token management
Fediverse Adminpanels/fediverse.js/dashboard/fediverseFediverse/ActivityPub administration
Historypanels/history.js/dashboard/historyVersion history of covered content (implementation plan Task 5): an item's versions with Restore, the Jobs page with Undo this job, and the Trash. Hidden panel — reached from an item (a memory, a blog post, a context section) and from the two cross-cutting pages (/dashboard/history/jobs, /dashboard/history/trash); its renderer is shared/history-view.js and its settings section is history (retention, Trash period, erase sync, size line). Every route is owner-only, GET included, because an item's history is its content

The largest panels are module directories rather than single files: panels/<name>/ holds {css,data-queries,client,api-handlers,html}.js (plus panel-specific modules like editor.js), with the top-level panels/<name>.js as a thin orchestrator that wires them together. bot-builder, bot-board, extensions, contacts, messages, and nest follow this pattern; smaller panels remain single files.

Settings sections live in servers/gateway/dashboard/settings/sections/ — including sync-conflicts.js, the multi-instance sync-conflict recovery view that conflict notifications deep-link to (/dashboard/settings?section=sync-conflicts).

Auth System ​

The Crow's Nest uses its own authentication layer, separate from the gateway's OAuth system.

Password Hashing ​

Passwords are hashed with Node.js's built-in crypto.scrypt:

js
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
  // Store salt + derivedKey
});

No external dependency required.

Sessions ​

After login, a session cookie is set with:

  • httpOnly: true — Not accessible to client-side JavaScript
  • sameSite: 'strict' — Prevents CSRF via cross-origin requests
  • secure: true — Only sent over HTTPS (when behind a reverse proxy)
  • Configurable expiry (default: 24 hours)

CSRF Protection ​

All state-changing requests (POST, PUT, DELETE) require a CSRF token. The token is embedded in forms as a hidden field and validated server-side.

Account Lockout ​

After 5 failed login attempts within 15 minutes, the account is locked for 30 minutes. This prevents brute-force attacks on the Crow's Nest password.

Requests from this machine ​

A request that comes from the machine Crow runs on is never the owner's own turn, whatever it carries. A browser container on host networking, a script, or a bot with a shell can set any header or cookie, and through Tailscale Serve it even carries your tailnet login; only its address gives it away. So isLocalPasswordSessionOn (servers/gateway/dashboard/local-session.js) refuses a request whose client (servers/shared/self-origin.js) is any of the machine's own addresses (tailnet and container bridges included), is inside a container or VM bridge subnet, comes through a proxy on this machine, or can't be determined. A login made from the machine is stored as dashboard onhost, so its cookie is never the owner's either, wherever it is used later.

Such a session still signs in and browses the Crow's Nest. What it can't do is anything that acts as you: use connected services, approve phone calls, change schedules or two-factor settings, reveal saved passwords, or start the owner's Perch and board turns. In practice:

  • The machine's own console (a monitor and keyboard on the Crow box) is not your own turn. Use your laptop or phone, through Tailscale Serve or the LAN.
  • A reverse proxy on the same machine gives up the owner's turn for everything that goes through it (see Custom domain).
  • A container bridge subnet that overlaps your LAN (Docker's 192.168.x pools on a 192.168.x home network) makes LAN clients in the overlap count as this machine unless the LAN interface's prefix is more specific. The gateway logs [net] WARN a container or VM bridge subnet overlaps this machine's LAN subnet once at start when this applies.
  • What it rests on: a bot that relays through another machine and logs in from there gets a normal session. That is guarded by the dashboard password staying secret, so don't save it in a browser profile that bots can drive.

Layout System ​

The layout function (shared/layout.js) wraps panel content in a consistent page structure. It takes a single options object:

js
renderLayout({
  title,        // page title
  content,      // panel HTML
  activePanel,  // highlights the nav entry
  panels,       // panel registry (for nav rendering)
  scripts,      // extra page scripts
  afterContent, // markup appended after content (e.g. modals)
  headerIcons,  // extra header icon slots
  lang,         // 'en' | 'es'
  navGroups,    // grouped nav sections
  instanceTabs, // multi-instance tab bar
})

Panels receive it as layout in their handler context and call it as layout({ title, content }).

Everything is a template literal — no template engine dependency. CSS is inlined in the <head> to avoid a separate static file server.

Network Security ​

Before any Crow's Nest route executes, isAllowedNetwork() (servers/gateway/dashboard/auth.js) decides on the TCP peer, classified once per request by classifyPeer() (servers/shared/peer-class.js). A header is never an allow reason on its own.

Peer classWhenClient addressAllowed?
localloopback socket, no forwarding or Tailscale-* headersthe socketno (a bare local process)
serveloopback socket opened by tailscaled's uid (CROW_SERVE_PEER_UIDS, default 0, checked in /proc/net/tcp), exactly one X-Forwarded-For in a tailnet range (100.64.0.0/10, fd7a:115c:a1e0::/48), X-Forwarded-Host present, no Funnel headerthe X-Forwarded-For addressyes
funnelthe same, with Tailscale-Funnel-Request and a public X-Forwarded-Forthe public clientnever (public paths only)
proxysocket listed in CROW_TRUSTED_PROXIES, or a connection accepted on the gateway's dedicated unix proxy socket (systemd owns it, marked with PROXY_SOCKET)rightmost X-Forwarded-For hop (null when the header names nobody, which denies); on the proxy socket exactly that hop, never an earlier oneif every forwarded address is LAN, tailnet or CROW_ALLOWED_IPS
directany other non-loopback socketthe socketif the socket and every forwarded address are LAN, tailnet or CROW_ALLOWED_IPS
unverifiedany other loopback request carrying forwarding or Tailscale-* headersthe socketno

Tailscale-User-Login is honoured only for serve, and is withheld when the Serve client is itself listed in CROW_TRUSTED_PROXIES (a relay chained into Serve cannot vouch for its visitors). Express's trust proxy is set to CROW_TRUSTED_PROXIES (default: trust no one); for serve and funnel traffic req.ip is the client tailscaled named, and on the dedicated proxy socket it is the client Caddy named, so rate limits and the login lockout keep per-client keys. To allow access from any IP, set CROW_DASHBOARD_PUBLIC=true (Funnel traffic is still refused).

The first time forwarding headers arrive from an undeclared peer, the gateway logs [net] WARN forwarding headers from undeclared peer … once.

The dedicated proxy socket (servers/gateway/proxy-socket.js) is how the installer's Caddy fronts the gateway. A loopback connection carrying X-Forwarded-For is unverifiable — any process on the box can write the same header — so it would be unverified and the owner would be turned away right after first-run setup (the security audit's finding). CROW_TRUSTED_PROXIES=loopback is not the fix (it makes every same-box caller the proxy and breaks the loopback-only doors). Instead systemd owns a socket and the gateway listens on the fd it inherits: crow-gateway-proxy.socket listens on /run/crow-proxy/gateway.sock as root:caddy with mode 0660 (DirectoryMode=0755 creates /run/crow-proxy), and crow-gateway.service declares Sockets=/Requires=/After=crow-gateway-proxy.socket, so systemd passes the listening fd by socket activation (LISTEN_FDS; the gateway reads LISTEN_FDNAMES and never creates, chmods or unlinks a path). Only the local Caddy can open that socket, so a process running as the gateway's own user — a default-tier bot — cannot dial it with a forged X-Forwarded-For and be classified proxy; and no user has to be added to the operator's group for Caddy to reach it. The gateway marks every connection accepted there and classifies it proxy with the client Caddy named, which is exactly the rightmost X-Forwarded-For hop (never an earlier one, even when that hop is itself in CROW_TRUSTED_PROXIES). Caddy 2 ignores a client-sent X-Forwarded-For unless trusted_proxies is configured, so the rightmost hop it sends is the real client. An install that still has the old reverse_proxy localhost:3001 Caddyfile and no socket unit gets a Fix-it card pointing at scripts/fix-caddy-proxy-socket.sh; the card resolves itself once the unit exists. Caddy's own admin API is pinned the same way: the installer's Caddyfile opens with admin unix//run/caddy/admin.sock, in a /run/caddy directory a caddy.service drop-in has systemd create (Caddy cannot create one under /run itself), so a local program or bot cannot POST /load a new config and rewrite the front door — an install from before that change gets a Fix-it card pointing at scripts/fix-caddy-admin-socket.sh, which resolves itself once the Caddyfile names an admin address.

The uid check fails closed: if the socket owner cannot be verified (no /proc/net/tcp, a read error, or the connection already gone), the request is unverified and Serve's headers are not honoured. On a host without procfs (macOS, Windows native), tailnet users reaching the gateway through Serve are therefore refused unless the operator opts in with CROW_SERVE_PEER_UIDS=off, which accepts Serve's shape alone and logs [net] WARN CROW_SERVE_PEER_UIDS=off … once. A miss is never cached: the next request on the same connection is checked again. If /proc/net/tcp stays unreadable on a Linux host, every Serve request is refused (public Funnel paths still answer); recover by reaching the gateway directly on its tailnet address (http://<tailnet-ip>:3001, a direct tailnet peer) or by setting CROW_SERVE_PEER_UIDS=off and restarting.

Residuals. A process running as tailscaled's uid (root) can forge Serve's shape. That includes root processes in host-network containers, for example a headless browser container that bots can drive (it can add headers over DevTools); the forged login and address then reach only rate-limit keys (login limiter, kiosk pairing, blog-embed tier, glasses lockouts) and audit fields, since such a process can already pass the network gate by dialling the host's own tailnet address. So can a process running as the gateway's uid where the operator opted out (CROW_SERVE_PEER_UIDS=off) or tailscaled shares the gateway's uid (each is warn-logged once). Any local process can reach a port that a declared proxy fronts (for example a container's docker-proxy port), and is then classified by that proxy's rules.

App Launcher ​

The Crow's Nest landing page (the "Crow's Nest" panel, navOrder: 5) includes a Your Apps grid showing installed add-ons as launcher tiles.

How it works ​

  1. Reads ~/.crow/installed.json and filters entries with type bundle or mcp-server
  2. Loads the add-on manifest to get the display name and webUI field
  3. Calls getAddonLogo(id, 48) from servers/gateway/dashboard/shared/logos.js for the tile icon (falls back to an initial-letter circle)
  4. For Docker-based add-ons, checks container status via docker ps --filter name=<id> with a 30-second module-level cache (_dockerStatusCache Map) to avoid excessive shell commands
  5. Renders a status dot (green = running, gray = stopped) and an "Open" button for add-ons with a webUI manifest field

Home Screen Tile Pipeline ​

The Nest home screen renders tiles from two sources:

  1. Panel Registry — getVisiblePanels() returns non-hidden panels sorted by navOrder
  2. Installed bundles — getNestData() reads ~/.crow/installed.json, loads manifests, checks Docker status

Data flow:

Panel Registry ──→ getVisiblePanels() ──┐
                                        ├──→ buildNestHTML() ──→ Grid
~/.crow/installed.json ──→ getNestData() ──┘

Tile ordering: Built-in panels first (by navOrder), then bundles (by installedAt from installed.json).

Icon resolution (bundles): Branded SVG logo → manifest icon field → first-letter circle fallback.

webUI manifest field ​

Add-on manifests can declare a webUI object to indicate the add-on has a browser-accessible interface:

json
{
  "webUI": {
    "port": 8080,
    "path": "/",
    "label": "Open Nextcloud"
  }
}

Set webUI to null for headless add-ons (e.g., Ollama). The launcher only shows the "Open" button when webUI is non-null.

Panel Auto-Installation ​

Add-ons that include a panel field in their manifest.json get their panel file automatically installed during add-on installation and removed during uninstallation. This works for any add-on type (bundle, mcp-server, skill), not just panel-type add-ons.

During install, routes/bundles.js copies the panel file from the add-on's source directory to ~/.crow/panels/ and adds its ID to ~/.crow/panels.json. During uninstall, the panel file is removed and the ID is deleted from the JSON. Example manifest field:

json
{
  "panel": "panels/podcast.js"
}

The Podcast panel (bundles/podcast/panels/podcast.js) is an example: it is installed as a third-party panel when the podcast add-on is installed.

PanelTypeSource
PodcastThird-party (auto-installed)bundles/podcast/panels/podcast.js

Third-Party Panels ​

Community-created panels live in ~/.crow/panels/. Each panel is a single JS file named <id>.js (an optional companion <id>-routes.js file can register extra routes). Panel IDs must match [a-zA-Z0-9][a-zA-Z0-9_-]{0,63}; anything else is rejected at load time. The Crow's Nest loads the panels listed in ~/.crow/panels.json on startup and registers any valid ones. Third-party panels receive the same { db, layout, appRoot, lang } context as built-in panels. The appRoot path points to the Crow source root, which panels can use for dynamic imports of shared components (e.g., logos.js, components.js); lang is the operator's dashboard language (en/es).

Enable panels in ~/.crow/panels.json (a JSON array of panel IDs):

json
["my-panel", "weather"]

An object format with an "enabled" key is also accepted for backward compatibility.

See Creating Panels for a development tutorial.

Notification System ​

The Crow's Nest includes a notification system with a bell icon and tamagotchi-style dropdown in the top bar.

The header has one button, the bird (tamagotchiHtml in shared/notifications.js; the bell replaces it when the bird is turned off). A tap opens the tray: notifications and system health. When the Kiosk extension is installed, the tray's first row is Talk to Crow and a long press on the bird (about half a second — mouse, touch, or a held Enter/Space) opens the voice overlay directly; the release never also opens the tray. The overlay, its close control and the gesture live in shared/crow-talk.js; the page inside it is the Kiosk extension's session display. Without the extension there is no row and a long press is an ordinary tap. Tests: tests/dashboard-bird-talk.test.js.

Schema ​

The notifications table stores all notifications:

ColumnTypeDescription
typetextreminder, media, peer, or system
sourcetextOrigin identifier (e.g., blog, sharing:message, bundle-installer)
titletextShort headline
bodytextOptional longer description
prioritytextlow, normal, or high
action_urltextDashboard link for click-through
is_readintegerRead status
is_dismissedintegerDismissed status
expires_attextAuto-expiry timestamp

Shared Helper ​

servers/shared/notifications.js exports two functions:

  • createNotification(db, opts) — Creates a notification after checking user preferences. Returns { id, delivery } or null if the type is disabled. With deliver: false it writes the row and refreshes the badge but sends nothing (no push, ntfy or email); delivery is the payload to pass to deliverNotification later, for example after a transaction commits. Always wrap calls in try/catch to prevent notification failures from breaking primary actions.
  • cleanupNotifications(db) — Removes expired notifications and enforces a 500-notification retention limit. Called by the scheduler tick and the REST GET endpoint.

User Preferences ​

Users configure which notification types are enabled in Settings → Notifications. Preferences are stored as a JSON object in dashboard_settings under the key notification_prefs:

json
{ "types_enabled": ["reminder", "media", "peer", "system"] }

All types are enabled by default. The createNotification helper checks this before inserting.

Event Sources ​

EventTypeSource
Blog post publishedmediablog
Incoming P2P sharepeersharing:share
Incoming Nostr messagepeersharing:message
Bundle installedsystembundle-installer
Bundle uninstalledsystembundle-installer
Scheduled reminderreminderscheduler

UI ​

The notification bell in the top bar shows an unread count badge. Clicking it opens a dropdown with recent notifications, each showing title, time, and source. Notifications can be dismissed individually or cleared in bulk. The REST API at /api/notifications provides JSON access for the dropdown's fetch calls.

No Build Step ​

The Crow's Nest has no build step, no bundler, and no node_modules of its own. All HTML, CSS, and minimal JavaScript are generated inline by the server. This keeps the UI lightweight and avoids frontend toolchain complexity.

CSS uses custom properties for theming (see the full Brand Identity table above):

css
:root {
  --crow-bg-deep: #eef1f3;
  --crow-bg-surface: #ffffff;
  --crow-accent: #0e6b62;
  --crow-text-primary: #22303a;
  --crow-brand-gold: #8f5606;
}

@media (prefers-color-scheme: dark) {
  :root {
    --crow-bg-deep: #131a1f;
    --crow-bg-surface: #1b242b;
    --crow-accent: #4fbdb0;
    --crow-text-primary: #e4ebef;
    --crow-brand-gold: #d9a521;
  }
}

First-run onboarding (F6b) ​

panels/onboarding.js is a hidden dashboard panel (hidden: true, route /dashboard/onboarding) that renders a 5-step guided tour (Welcome, Integrations, Bot, Connect, Done) driven by a ?step=N query param — server-rendered, no client JS. It is orient-and-route: each step explains one thing and deep-links (new tab) to the surface that does the work (Settings → Integrations, Bot Builder, the Connect wizard). It writes nothing.

It is shown automatically once: POST /dashboard/login redirects to it the first time a password is set (wasFirstSetup branch in index.js); normal logins go straight to /dashboard. It is replayable anytime via the "Replay setup guide" link in Settings → Help & Setup.

Copy is bilingual (EN/ES) via the onboarding.* keys in shared/i18n.js; the handler resolves language cookie-first (crow_lang) so a user who chose Spanish at setup gets Spanish onboarding. Tests: tests/onboarding.test.js.

Connect wizard (F6c-1) ​

panels/connect.js is a hidden dashboard panel (hidden: true, route /dashboard/connect) that gives per-client, copy-paste MCP config — server-rendered, no client JS beyond the shared tabs/copy handlers. A tabs() strip covers the local clients that can reach a private Crow (Claude Code, Cursor, Cline, Gemini CLI, Claude Desktop), each with the two connection styles that work today with no token: local stdio (npm run mcp-config) and remote HTTP via OAuth (paste an http server entry; the client runs the OAuth handshake on first use). Configs embed the request-host endpoint ${req.protocol}://${req.get("host")}/router/mcp (same base-URL derivation as the Connections settings section), so the snippet shows the address the operator is actually browsing from.

A sixth tab (claude.ai / ChatGPT) shows an honest reachability warning instead of a config: a private Crow is Tailnet-only and exposing MCP via Funnel is blocked by the network-exposure invariant, so cloud web clients cannot connect.

It is reached from onboarding step 3, the Help & Setup settings section, and the Connections settings section (all of which now point here rather than duplicating per-platform setup). Copy is bilingual (EN/ES) via the connect.* keys in shared/i18n.js, resolved cookie-first like onboarding. Tests: tests/connect.test.js.

Local MCP token (F6c-2) ​

The connect panel also manages a single, per-instance, full-access static bearer token for headless / no-browser clients (the remote-HTTP path that cannot run the OAuth handshake). The gateway verifies it server-side via servers/gateway/local-token.js: localTokenAuthMiddleware mounts right after instanceAuthMiddleware (and reads the DB only on MCP-transport paths — /mcp, /sse, /messages — as a cost guard), and a branch in routes/mcp.js's skipAuthForInstance calls applyLocalTokenAuth(req) to synthesize full local-operator req.auth (after the instance branch, before the OAuth fallback, and deliberately not run through the peer exposure gate).

Only sha256(token) is stored, in a local-scoped dashboard setting (mcp_local_token_hash, plus mcp_local_token_created) that never replicates to paired instances. The raw token is revealed exactly once on generate/rotate, embedded in a ready-to-paste http config with an Authorization: Bearer … header; the masked state shows only a <YOUR-TOKEN> placeholder. Generate/rotate/revoke are POST actions on the panel itself (CSRF + dashboardAuth gated) and need no gateway restart, because the verifier reads the hash per request. Comparison uses crypto.timingSafeEqual; under --no-auth the token branch is inert (dev-only). The Connections settings section links here for token generation. Tests: tests/connect-token.test.js. Spec in the private engineering notes.

This supersedes the earlier CROW_LOCAL_MCP_TOKEN env var, which only fed the npm run mcp-config --http build script and authenticated nothing server-side.

Released under the MIT License.