# Example cells (/docs/guides-examples)

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](https://github.com/raine/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 [#take-one]

```sh
solitary doctor
solitary clone balakin/solitary/examples/claude
solitary up claude
solitary shell claude
```

`doctor` 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](/docs/commands#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:

```sh
solitary clone balakin/solitary/examples/claude --as review
```

The 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` [#what-is-in-claude]

`solitary shell claude` opens a shell in the container with everything already there:

```text
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, jq
```

Both cells declare one secret, `GITHUB_TOKEN`, and it is optional:

```yaml
secrets:
  GITHUB_TOKEN:
    required: false
    description: gh — cloning, pushing branches and opening pull requests
```

`up` 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](/docs/guides-secrets).

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](/docs/concepts#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](/docs/concepts#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:

```text
[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` [#what-is-in-vscode]

```sh
solitary clone balakin/solitary/examples/vscode
solitary up vscode
```

Then 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](/docs/configuration#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:

```yaml
ports:
  - 9797 # the editor
  - 3000 # next, remix, rails
  - 5173 # vite
  - 8080 # everything else
```

Anything 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 [#the-editor-comes-configured]

Eight extensions are installed at build time, from Open VSX: Claude Code, ESLint, Prettier, Tailwind CSS, EditorConfig, [oxc](https://oxc.rs) 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`:

```json title="examples/vscode/editor-defaults.json"
{
  "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 [#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.

```yaml
command: cell-init && exec sleep infinity
```

It 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 [#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`:

```yaml
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.io
```

For `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](/docs/guides-network-policy) covers what the cell sees when a request is refused.

## Make it yours [#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:

```sh
solitary clone balakin/solitary/examples/claude --force
```

See [sharing cells](/docs/sharing) for what `clone` refuses to copy, and [use an image or build a container](/docs/guides-images) for swapping the toolset out entirely.
