This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Install all dependencies
npm run install:all
# Start (builds client, serves production UI + API on port 5555)
npm start
# Development (Vite hot-reload on 5554, API on 5555)
npm run dev
# Run tests
cd server && npm test
cd server && npm run test:watch # Watch mode
# PM2 management
pm2 start ecosystem.config.cjs
pm2 stop ecosystem.config.cjs
pm2 logsPortOS is a single-user, single-instance application running on a private network behind Tailscale VPN. It is not exposed to the public internet. There is exactly one user and one server process — concurrent request races, mutex locking on file I/O, and atomic write patterns are unnecessary and should not be added or flagged as concerns. Simple re-entrancy guards (e.g., per-account sync locks to prevent duplicate in-flight operations) are fine and expected. As such, it intentionally omits authentication, CORS restrictions, rate limiting, HTTPS, and full concurrency controls — these are non-issues for its deployment context. Do not add these or flag their absence as security concerns.
The default database password portos (in ecosystem.config.cjs, docker-compose.yml, and .env.example) is an intentional backward-compatible fallback for local development. Do not remove it or flag it as a security concern. Production deployments override it via the PGPASSWORD environment variable.
PortOS is a monorepo with Express.js server (always user-facing on :5555, HTTP or HTTPS) and React/Vite client (Vite dev server on :5554 in npm run dev; in npm start the built client is served from :5555 directly). PM2 manages app lifecycles. Data persists to JSON files in ./data/.
PortOS uses ports 5553-5561. In native mode, PostgreSQL uses the system pg on port 5432; in Docker mode, port 5561.
The user-facing port is always :5555 — its scheme flips between HTTP and HTTPS based on whether a TLS cert is provisioned (npm run setup:cert), but the port number does not. When HTTPS is on, a loopback-only HTTP mirror also spawns on :5553 so local curl/scripts don't have to deal with cert warnings. :5554 is the Vite dev server, used only in npm run dev.
Define all ports in the top-level PORTS object in ecosystem.config.cjs (see server/lib/ports.js for the canonical re-export). See docs/PORTS.md for the full port allocation guide and a diagram of how :5555, :5553, and :5554 relate.
- Routes: HTTP handlers with Zod validation
- Services: Business logic, PM2/file/Socket.IO operations
- Lib: Shared validation schemas
- Pages: Route-based components
- Components: Reusable UI elements
- Services:
api.js(HTTP) andsocket.js(WebSocket) - Hooks:
useErrorNotifications.jssubscribes to server errors, shows toast notifications
Client → HTTP/WebSocket → Routes (validate) → Services (logic) → JSON files/PM2
PortOS depends on portos-ai-toolkit as an npm module for AI provider management, run tracking, and prompt templates. The toolkit is a separate project located at ../portos-ai-toolkit and published to npm.
Key points:
- Provider configuration (models, tiers, fallbacks) is managed by the toolkit's
providers.js - PortOS extends toolkit routes in
server/routes/providers.jsfor vision testing and provider status - When adding new provider fields (e.g.,
fallbackProvider,lightModel), update the toolkit'screateProvider()function - The toolkit uses spread in
updateProvider()so existing providers preserve custom fields, butcreateProvider()has an explicit field list - After updating the toolkit, run
npm update portos-ai-toolkitin PortOS to pull changes
PortOS has a single source of truth for navigation: server/lib/navManifest.js exports NAV_COMMANDS (every navigable page: { id, path, label, section, aliases, keywords }) and resolveNavCommand() (the fuzzy resolver). It is consumed by:
- The
⌘KCommand Palette (client/src/components/CmdKSearch.jsx) viaGET /api/palette/manifest. - The voice agent's
ui_navigatetool (server/services/voice/tools.js) — so "take me to tasks" resolves through the same map the palette uses.
When adding a new page, you MUST also add an entry to NAV_COMMANDS. Adding only a <Route> in App.jsx and a sidebar link in Layout.jsx will leave the page unreachable from ⌘K and un-navigable by voice. Entry shape:
{ id: 'nav.<section>.<slug>', path: '/foo/bar', label: 'Bar', section: 'Foo',
aliases: ['foo-bar', 'bar'], keywords: ['synonyms', 'context'] }id— stable, dotted (nav.brain.inbox). Must be unique.path— exact route the client router matches; must start with/.section— matches the sidebar group label so the palette and sidebar stay visually aligned.aliases— short spoken/typed tokens the user is likely to say. The voice agent's fuzzy resolver tries each alias with tiered matching; more aliases = more forgiving voice navigation.keywords— extra terms used only by the palette's in-UI scorer (synonyms, feature names).
Fail-fast guards at module load catch missing fields, non-slash paths, and duplicate ids — so a bad entry blocks server boot instead of silently breaking palette/voice.
For NEW voice-tool-style actions that should appear in ⌘K: add the tool to server/services/voice/tools.js (it's the single source of action schemas), then whitelist its id in the PALETTE_ACTIONS array in server/routes/palette.js with a section + label. Do not duplicate the tool's description or parameters — the palette route hydrates them from getToolSpecs() at request time. DOM-driving tools (ui_click, ui_fill, etc.) stay off the palette whitelist because the palette has no live DOM context.
Tests: server/lib/navManifest.test.js asserts shape invariants + alias resolution; server/routes/palette.test.js asserts the manifest endpoint + action dispatch + whitelist enforcement. Any new entry is automatically covered by the shape-invariant tests.
Dashboard widgets are registered in client/src/components/dashboard/widgetRegistry.jsx — each entry has { id, label, Component, width, defaultH?, gate? }. The Dashboard page renders the active layout's widget list from this registry; named layouts persist in data/dashboard-layouts.json and are managed via GET/PUT/DELETE /api/dashboard/layouts. Built-in layouts (default, focus, morning-review, ops) are seeded on first read and cannot be deleted.
Grid positions: layouts also carry a grid: [{ id, x, y, w, h }] array — free-form positions on a 12-column grid (rows ~80px each). When grid is empty (legacy/unmigrated layouts) the renderer auto-flows widgets using synthesizeGrid based on each widget's width keyword and defaultH. The "Arrange" button on the Dashboard enters edit mode where every widget exposes a move (top-right) and resize (bottom-right) handle; drag is snap-to-grid with collision-resolve via placeAndCompact (pins the moved item, slots others into the smallest non-colliding y). Save persists to the active layout's grid. The grid renderer collapses to a single-column stack below 640px viewport width — drag/resize is desktop-only.
When adding a new dashboard widget:
- Add a
{ id, label, Component, width, defaultH?, gate? }entry toWIDGETSinwidgetRegistry.jsx. Use a stableid(kebab-case) — it's the contract stored in layouts. PickdefaultHbased on the widget's natural content height (default4); this controls the size when it's first auto-placed into a grid. - If the widget needs dashboard data (apps/usage/health), read it from the
dashboardStateprop — do NOT issue a duplicate fetch from inside the widget. - If the widget only makes sense in some cases (e.g. only when apps exist), add a
gate: (state) => booleanpredicate. - Add the widget id to the built-in
defaultlayout inserver/services/dashboardLayouts.jsif it should appear out of the box. - Users can toggle widgets on/off per layout via the Dashboard's layout picker → Edit, and arrange/resize them via the "Arrange" button.
Switching layouts is also wired into the ⌘K palette — it synthesizes a Dashboard: <name> command per layout at palette-open time, so any layout the user creates is instantly keyboard-reachable without further registration.
PortOS bundles slashdo as a git submodule at lib/slashdo. This provides slash commands (/do:review, /do:pr, /do:push, /do:release, etc.) and shared libraries without requiring a separate global install.
Key points:
- Submodule lives at
lib/slashdo, symlinked into.claude/commands/do/and.claude/lib/ npm run install:allrunsgit submodule update --init --recursiveautomatically- To update slashdo:
git submodule update --remote lib/slashdo - CoS agents can use
loadSlashdoCommand(name)fromsubAgentSpawner.jsto inline command content into prompts (resolves!catlib includes automatically) - The
.claude/commands/do/symlinks make all/do:*commands available as project-level Claude Code slash commands
When CoS agents or AI tools work on managed apps outside PortOS, all research, plans, docs, and code for those apps must be written to the target app's own repository/directory -- never to this repo. PortOS stores only its own features, plans, and documentation. If an agent generates a PLAN.md, research doc, or feature spec for another app, it goes in that app's directory.
When working directly in the Claude Code TUI with the user driving, edit the main repo directly — don't spawn a worktree. Use normal feature branches and PRs (or push to main) when the work is done. The user is at the keyboard, so there is no risk of stepping on in-flight work.
Worktrees are required only for CoS sub-agents spawned out of server/services/cos/subAgentSpawner.js. Those run unattended in parallel and need isolation so they don't trample each other or the user's working tree. The worktree manager (server/services/cos/worktreeManager.js) handles this automatically — TUI sessions should not duplicate that behavior.
- No try/catch - errors bubble to centralized middleware
- No window.alert/confirm - use inline confirmations or toast notifications
- Linkable routes for all views - tabbed pages use URL params, not local state (e.g.,
/devtools/historynot/devtoolswith tab state) - Functional programming - no classes, use hooks in React
- Zod validation - all route inputs validated via
lib/validation.js - Command allowlist - shell execution restricted to approved commands only
- Mobile responsive - all pages should be mobile responsive friendly
- Above the fold - keep actionable content and info above the fold and design pages for maximum information and access without scrolling
- No hardcoded localhost - use
window.location.hostnamefor URLs; app accessed via Tailscale remotely - Alphabetical navigation - sidebar nav items in
Layout.jsxare alphabetically ordered after the Dashboard+CyberCity top section and separator; children within collapsible sections are also alphabetical - Every new page registers in the nav manifest - when adding a
<Route>+ sidebar link, also add aNAV_COMMANDSentry inserver/lib/navManifest.js. This makes the page reachable via⌘Kand voice (ui_navigate) automatically. See the "Command Palette & Voice Nav" section above for the entry shape. - Reactive UI updates - after mutations (delete, create, update), update local state directly instead of refetching the entire list from the server. Use
setState(prev => prev.filter(...))or similar patterns for immediate feedback - Single-line logging - use emoji prefixes and string interpolation, never log full JSON blobs or arrays
console.log(`🚀 Server started on port ${PORT}`); console.log(`📜 Processing ${items.length} items`); console.error(`❌ Failed to connect: ${err.message}`);
port-bg: #0f0f0f port-card: #1a1a1a
port-border: #2a2a2a port-accent: #3b82f6
port-success: #22c55e port-warning: #f59e0b
port-error: #ef4444
- main: Active development
- release: Push
maintoreleaseto trigger GitHub Release workflow - Push pattern:
git pull --rebase --autostash && git push - Changelog: Append entries to
.changelog/NEXT.mdduring development;/do:release(Claude Code slash command) finalizes it into a versioned file - Versioning: Version in
package.jsonreflects the last release. Do not bump during development —/do:releasehandles version bumps - After each feature or bug fix, run
/simplifyand then commit and push code - If we have created enough commits to wrap up a feature or issue to warrant a production release, pull the latest main and release branches and then run
/do:releasefrom main
See .changelog/README.md for detailed format and best practices.