Example cells
Two complete cells to clone — a terminal setup around claude, and an editor in the browser.
The repository holds two cell definitions that are meant to be run rather than read. Each is a cell.yaml, the Containerfile it builds from, and a skel/ directory of dotfiles.
claude— a terminal working environment: claude, Neovim with LazyVim, tmux, workmux,gh, and node from nvm with corepack enabled.vscode— an editor reached from the host's browser: openvscode-server with the extensions a web project wants, a dark theme, and the ports a dev server listens on, plus claude,gh, node and corepack.
Take one
solitary doctor
solitary clone balakin/solitary/examples/claude
solitary up claude
solitary shell claudedoctor comes first because both examples ask for 6GiB, and its memory check is the one that says whether every cell you have defined fits at once — see doctor checks the host. If it warns, lower vm.memory in the cloned cell.yaml; it is a machine setting, so it takes effect at the cell's next boot rather than immediately.
clone shows you what the definition asks for before installing anything, and what lands is a copy with no link back here. --as <name> takes one under a name of your own, which is how you keep a second copy of the same example — one to change, one to fall back to:
solitary clone balakin/solitary/examples/claude --as reviewThe first up builds the image inside the cell's own machine, so nothing in either Containerfile runs on the host. It is a slow first build — the claude cell installs Neovim's plugins and its treesitter parsers during it, which is what makes opening nvim in a fresh cell instant afterwards.
What is in claude
solitary shell claude opens a shell in the container with everything already there:
claude the agent, from npm
nvim LazyVim, plugins and treesitter parsers preinstalled
tmux status line at the top, mouse on
workmux one agent per git worktree, each in its own tmux window
node, pnpm node from nvm, pnpm through corepack
gh the GitHub CLI, authenticated from GITHUB_TOKEN
lazygit, rg, fd, jqBoth cells declare one secret, GITHUB_TOKEN, and it is optional:
secrets:
GITHUB_TOKEN:
required: false
description: gh — cloning, pushing branches and opening pull requestsup offers it and enter skips it, so a cell with no token starts with the variable simply absent. It provides authentication without a browser launch from inside the cell; alternatively, open a displayed device sign-in URL yourself. Give it a fine-grained token scoped to the repositories that cell works on, and solitary secrets claude rotates it later. See pass secrets safely.
Sign claude in once inside the cell. The cell's home is a directory on the machine rather than in the container, so the login survives every change that replaces the container — a new image, an edited Containerfile, a rotated secret. It does not survive solitary rm, which discards the machine's disk and the home with it. Declaring ANTHROPIC_API_KEY under secrets: is the other way round: the value is held on the host, so it outlives the cell entirely. See changing a cell.
claude does not update itself in either cell: both images set DISABLE_AUTOUPDATER, and silence the update notices of gh and npm. The container is started over from the image at every boot, so an update downloaded inside it would be downloaded again after every restart. Run solitary up --rebuild claude — or press b in the dashboard — to build the image again with the latest claude in it. What the Containerfile pins stays pinned until you change it. See updating the tools in a cell.
workmux has its sandbox turned off in this cell, deliberately. It can put each worktree in a Lima VM of its own, which is the isolation solitary has already provided, and it cannot nest: the cell is the sandbox.
The seeded ~/.claude/settings.json sets skipDangerousModePermissionPrompt, so claude does not ask you to confirm that you meant to skip permissions. Make that choice deliberately: the VM separates host files and the host kernel, but these examples leave networking open and can reach host services. Delete the line if you would rather be asked, and configure network.allow to limit egress.
The statusline shows how much of each rate-limit window is left as a duration rather than as a wall-clock time:
[Opus 5] medium | ctx:34% | 5h:12% (2h5m) | 7d:41% (3d4h)A cell has no reason to agree with the host on a timezone, and 2h5m means the same thing in both.
What is in vscode
solitary clone balakin/solitary/examples/vscode
solitary up vscodeThen open http://localhost:9797. The port is 9797 rather than openvscode-server's own 3000 because 3000 belongs to whatever dev server you are there to work on; it is written twice in cell.yaml, in --port and in ports:, and the two move together.
The server requires a random connection token, kept in the cell's home. Read its authenticated URL with limactl shell solitary-vscode podman logs solitary and open that URL on the host. Forwarding to host localhost limits network exposure, but other local users and processes can connect; keep authentication enabled. See ports.
That same forward is the thing to remember when you start a dev server inside the editor. Naming any port at all turns off forwarding every port the cell listens on, so the cell names the three a web project reaches for first alongside the editor's:
ports:
- 9797 # the editor
- 3000 # next, remix, rails
- 5173 # vite
- 8080 # everything elseAnything listening on another port needs it added here, and ports is read when the machine boots — solitary down vscode && solitary up vscode is what applies a change to it.
The editor comes configured
Eight extensions are installed at build time, from Open VSX: Claude Code, ESLint, Prettier, Tailwind CSS, EditorConfig, oxc and Astro, plus GitHub's themes. Claude Code's extension is there as well as the CLI — it drives the same agent from inside the editor, against the cell's own installation.
The editor's defaults — a dark theme, format on save, Prettier formatting while ESLint only fixes, and telemetry off — are built into the image, from editor-defaults.json beside the Containerfile:
{
"workbench.colorTheme": "GitHub Dark Default",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.format.enable": false,
"editor.tabSize": 2,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"terminal.integrated.defaultProfile.linux": "bash",
"workbench.startupEditor": "none",
"telemetry.telemetryLevel": "off"
}Not as a settings file, because that would not work. The web workbench keeps its user data in the viewer's browser — IndexedDB, per origin — rather than in the server's --user-data-dir, so a settings.json placed inside the cell is never read by the editor, and openvscode-server has no flag for defaults either: its product.overrides.json is read only by a development build. What upstream does define is configurationDefaults, one of the options the page hands to create(), and nothing sets it — so the Containerfile injects it into the workbench bootstrap and checks that the result still parses. A bundle that no longer matches fails the build rather than quietly dropping the defaults.
They are defaults, so anything you change in the editor is kept in your browser and still wins. That is the catch worth knowing, and it runs the other way too: those changes are per browser and per origin, they are not in the cell, and solitary rm does not clear them. Reset the site's data in the browser to get back to the defaults.
Extensions are the opposite — they live on the server, so the cell does own them. They are seeded into its home the first time the container starts rather than left in the image, so an extension you install from inside the editor stays installed and a rebuilt image does not remove it.
They need one thing more, which seed-extensions does at every start: the editor reads extensions.json and ignores any directory not named in it, so a home seeded before the image gained an extension would hold the directory and never show the extension. The two lists are merged, and the home's entry wins wherever both name one, so a version you installed yourself is left alone. An extension the image ships comes back if you uninstall it — take it out of the Containerfile instead.
How the dotfiles get there
A cell's home is mounted over the container's, so anything an image writes under $HOME is hidden the moment the container runs — and the home starts empty. Both examples handle that the same way: the image ships its dotfiles under /opt/cell-skel, and cell-init copies in whatever is missing when the container starts.
command: cell-init && exec sleep infinityIt never overwrites. Your edits survive a rebuild, and a file added to the skel later still arrives — but a file that changed in the skel does not, because the home already has one. That is the trade, and it is the right way round for a dotfile: to take a new version of one, copy it in from /opt/cell-skel/ yourself. Anything that has to reconcile rather than copy — the editor's extension list, above — needs a step of its own, and anything the cell does not own at all, like the web editor's settings, cannot be seeded from here.
Restrict what it reaches
Neither example sets network.allow, so both reach whatever the host reaches. This is a connectivity default, not a host-network isolation guarantee: the allow list gates the build as well as the running cell, and a domain missing from it fails the build rather than one request.
Add it once the cell works. For claude:
network:
allow:
# while it builds
- archive.ubuntu.com
- security.ubuntu.com
- github.com
- githubusercontent.com
- nodejs.org
- registry.npmjs.org
# while it runs
- api.anthropic.com
- statsig.anthropic.com
- sentry.ioFor vscode, the same list plus open-vsx.org — needed at build time for the extensions, and again at run time if you install one from inside the editor. Either way, add whatever the code you work on has to reach.
A domain covers its subdomains, and setting allow at all makes the cell default-deny. Network policy covers what the cell sees when a request is refused.
Make it yours
A cloned cell is a copy. Edit its cell.yaml or its Containerfile under ~/.config/solitary/cells/<name>/ and run up again — the image rebuilds when the build context changed, and the cell's home is kept.
The versions each cell pins live in the ARG lines at the top of its Containerfile, which is the one place to change to move Neovim, node or the editor.
To take an updated definition from here without losing your .env or anything else you added:
solitary clone balakin/solitary/examples/claude --forceSee sharing cells for what clone refuses to copy, and use an image or build a container for swapping the toolset out entirely.