@buildwithdan/shellos
v0.2.0
Published
A macOS-style desktop shell for React — windows, dock, and an app-as-plugin registry.
Readme
shellOS
A macOS-style desktop shell for React — windows, dock, and an app-as-plugin registry. Presentation-layer only: no backend, auth, or persistence opinions.
npm install # once
npm run dev # → http://localhost:5173The 30-second consumer story
Your project becomes a desktop by registering apps. An app is an id, a name, an icon, and a React component:
import { Shell, type ShellAppDefinition } from '@buildwithdan/shellos'
import '@buildwithdan/shellos/styles.css'
const apps: ShellAppDefinition[] = [
{
id: 'notes',
name: 'Notes',
icon: <NotesIcon />, // any ReactNode — SVG, <img>, or an emoji string
window: NotesApp, // any React component
defaultWindow: { width: 640, height: 430 },
},
]
export default function Desktop() {
return <Shell apps={apps} autoOpen={['notes']} />
}The shell owns window management (open/close/drag/resize/focus/z-order/minimize/zoom), the dock (launching, running indicators, magnification, minimized-window thumbnails right of the divider), and the menu bar. Your app owns everything inside its window.
API reference
<Shell>
| Prop | Type | Notes |
| --- | --- | --- |
| apps | ShellAppDefinition[] | Required. Dock order follows array order. Changing the array re-registers (windows of removed apps close). |
| wallpaper | string \| ReactNode | A string is a CSS background-image value (url(...) or any gradient); a node renders full-bleed. Defaults to the built-in gradient. |
| autoOpen | string[] | App ids launched on mount, staggered. Skipped when persistence restores a saved layout. |
| persistence | ShellPersistenceAdapter | Restores the saved layout on mount and saves changes back, debounced. See Persistence. |
| appearance | 'light' \| 'dark' \| 'system' | Chrome appearance; 'system' follows the OS live. Default 'light'. The resolved value lands on the root as data-appearance, so app content themes itself via .sos-shell[data-appearance='dark'] .my-app. |
| className, style | | Passed to the root element. |
<Shell> fills its nearest sized ancestor — give it a full-viewport parent for the classic experience.
ShellAppDefinition
| Field | Type | Notes |
| --- | --- | --- |
| id | string | Unique + stable. Window ownership and programmatic open key off it. |
| name | string | Dock tooltip + menu bar text. |
| icon | ReactNode | Rendered inside a square tile the shell sizes and magnifies. Strings (emoji) get a default glassy tile. |
| window | ComponentType | Receives no props by design — apps talk to the shell through hooks. |
| defaultWindow | WindowDefaults | width/height/x/y/minWidth/minHeight, all optional. Unpositioned windows center, then cascade. |
| allowMultiple | boolean | Default false: launching an already-running app focuses/restores its window (macOS dock behavior). |
| menus | ShellMenu[] | Menu-bar dropdowns shown while this app is focused. See Menus. |
useShell() — anywhere under <Shell>
const shell = useShell()
shell.open('notes') // launch (or focus/restore) → windowId | null
shell.close(windowId)
shell.focus(windowId)
shell.minimize(windowId)
shell.restore(windowId)
shell.apps // registered apps
shell.windows // live ShellWindowState[]
shell.focusedWindowIduseShellWindow() — inside an app's window component
const win = useShellWindow()
win.id; win.appId; win.isFocused; win.isMaximized
win.close(); win.minimize(); win.focus()
win.setTitle('Groceries') // dynamic title-bar textMenus
Apps can declare menu-bar dropdowns; the bar renders the focused app's menus macOS-style (press to open, slide across titles, Escape or click-away to close; keyboard activation via Enter/Space):
{
id: 'notes',
// ...
menus: [
{
label: 'File',
items: [
{ label: 'New Note', onSelect: () => myNewNoteAction() },
'separator',
{ label: 'Close Window', onSelect: (shell) => shell.close(shell.focusedWindowId!) },
],
},
],
}onSelect receives the live ShellAPI, so window and app actions need no extra wiring. App-internal actions (like "New Note") bridge however you prefer — an event, a store, a callback into your state. Items support disabled; menus are static per registration (re-register the app set to change them).
Persistence
The shell has no storage opinion — you hand it an adapter and it handles the rest:
import { createLocalStorageAdapter, Shell } from '@buildwithdan/shellos'
const persistence = createLocalStorageAdapter('my-desktop') // or your own
<Shell apps={apps} autoOpen={['notes']} persistence={persistence} />An adapter is two functions, sync or async:
interface ShellPersistenceAdapter {
load(): ShellSnapshot | null | undefined | Promise<ShellSnapshot | null | undefined>
save(snapshot: ShellSnapshot): void | Promise<void>
}Back it with anything that holds JSON — createLocalStorageAdapter() ships in the box; a per-user database row works the same way. Semantics:
loadreturningnullmeans "no saved layout" →autoOpenruns. An existing snapshot — even an empty one — is the user's layout and restores as-is.- Saves are debounced (250ms) and flushed on
pagehide, so the last drag before closing the tab isn't lost. - The snapshot holds window layout only (geometry, stacking, titles, minimize/zoom state). App content is yours to persist — same rule as all data in shellOS.
- Restored windows whose app id is no longer registered are dropped; geometry from a larger screen is clamped back into reach.
- Headless / custom setups can call
persistShellStore(store, adapter)directly.
The agent surface
The shell's capabilities ship as LLM-ready tool calls, so an AI can drive the desktop — the "watch the agent use the computer" demo moment.
import { Shell, executeShellTool, shellToolDefinitions, type ShellAPI } from '@buildwithdan/shellos'
// 1. A live handle for code OUTSIDE the React tree (an agent loop, shortcuts, tests):
const shellRef = { current: null as ShellAPI | null }
<Shell apps={apps} shellRef={shellRef} />
// 2. Hand the tools to your model, route its calls back:
// shellToolDefinitions → list_desktop, open_app, close_window,
// focus_window, minimize_window, restore_window (JSON Schema inputs)
const { result } = executeShellTool(shellRef.current!, 'open_app', { appId: 'notes' })Wiring a real model is the consumer's side of the boundary — shellOS has no model, streaming, or provider opinions. With the Vercel AI SDK it's one mapping:
import { tool, jsonSchema } from 'ai'
const tools = Object.fromEntries(
shellToolDefinitions.map((t) => [
t.name,
tool({
description: t.description,
inputSchema: jsonSchema(t.inputSchema),
execute: async (input) => executeShellTool(shellRef.current!, t.name, input).result,
}),
]),
)Failures (unknown ids, missing inputs) come back as ok: false results phrased for the model — feed result back and let it self-correct; nothing throws. Inside a window, useShell() returns the same ShellAPI, so an in-desktop "agent app" (see the demo's Agent) needs no ref at all.
Advanced
ShellStore (the framework-free window manager) and MENU_BAR_HEIGHT are exported for headless tests and power use. ShellStore.snapshot() / .hydrate() are the primitives under the persistence prop. createShellAPI(store, getViewport?) builds the same ShellAPI that shellRef receives, directly over a bare store.
Repo layout
packages/shellos the publishable library (tsup → ESM + CJS + d.ts, styles.css)
apps/demo Vite playground; imports `@buildwithdan/shellos` like a real consumernpm run dev (demo with HMR into library source) · npm run test (core unit tests) · npm run build (library build) · npm run typecheck
Releasing
Published to npm as @buildwithdan/shellos — scoped because the registry rejects bare shellos as too similar to shelljs. From packages/shellos: bump version, then npm publish — prepublishOnly regenerates the package README from the root one and rebuilds dist/ automatically. Sanity-check the tarball first with npm pack --dry-run.
Principles
- Apps are plugins. Consumers register icon + window component; the shell owns the rest.
- No data/auth/persistence opinions. Interfaces over implementations.
- Demo polish is a feature. Springs, shadows, and dock magnification are the product, not decoration.
