Self-hosting
Configure and run your own Calandria instance: Docker, tunnels, auth, and every
env var. The README covers the two-command quick start; this
page is the rest. If something is broken, see
TROUBLESHOOTING.md for first-incident runbooks (DB
corruption, disk fill, headless re-auth, boot failures).
Docker
The Dockerfile builds a single-user image: a production
Next.js build on Node 26, with git and the claude CLI installed.
docker/entrypoint.sh runs both processes (app
server and pty sidecar) under tini. All state lives under /home/calandria
in one named volume: the SQLite database, worktrees, project repos, and the
claude login.
You don’t have to build the image yourself. The published image builds
nightly from main, from v* release tags, and on manual dispatch. Ordinary
pushes to main no longer trigger a build; test.yml still runs on every
push.
The published image
docker pull ghcr.io/calandria-dev/calandria:latestThe package is public: no docker login, no token needed.
.github/workflows/publish-image.yml
publishes it, gated on the test suite (types, unit, e2e), so a red run never
reaches the registry.
One manifest covers linux/amd64 and linux/arm64; docker pull picks the
right one for you.
| Tag | Points at |
|---|---|
latest | The newest tagged release |
edge | The newest nightly build of main |
sha-<short> | One immutable tag per commit (sha-337ea62, the 7-char SHA) |
<version> | A pushed v* git tag with the v stripped (v1.4.2 → 1.4.2) |
<major>.<minor> | The newest patch on that line (e.g. 1.4) |
latest moves only on a v* tag push and only moves forward: it never
points at untagged, unreleased code. Nightly builds of main publish under
edge instead. Pin sha-<short> (or a specific X.Y.Z) for a tag that never
changes under you; see Pinning a version below.
Every release publishes all three tags: latest, <version>, and
<major>.<minor>, for example 0.3.0 and 0.3 for a v0.3.0 tag (no v).
The releases page
lists what latest has pointed at over time.
Verify the image’s provenance
The attest job signs the merged multi-arch index with SLSA build
provenance (Sigstore, keyless): a signed claim that this digest was built by
this workflow, from this commit. Check it before you run the image:
gh attestation verify oci://ghcr.io/calandria-dev/calandria:latest --owner calandria-devSuccess is the exit status; gh prints nothing when its output isn’t a
terminal. Add --format json for the parsed statement and signing
certificate.
| Flag | Effect |
|---|---|
--repo calandria-dev/calandria | Scopes the claim to this repo instead of anything the account publishes |
--signer-workflow calandria-dev/calandria/.github/workflows/publish-image.yml | Pins which workflow was allowed to sign |
--bundle-from-oci | Reads the signature from the registry instead of the GitHub API |
The subject is the multi-arch index digest, since that’s what you pull. Only
digests published by a run that included the attest job carry a signature;
anything older reports no attestations found.
Pinning a version
The image tag carries no leading v, even though the git tag does: v0.2.0
in git is :0.2.0 in the registry. :vX.Y.Z does not exist and fails to
pull.
Pick one:
| You want | Set CALANDRIA_IMAGE to |
|---|---|
| A specific release, never changes | ghcr.io/calandria-dev/calandria:X.Y.Z |
| The newest patch on a minor line | ghcr.io/calandria-dev/calandria:X.Y |
| Whatever the newest release is | ghcr.io/calandria-dev/calandria:latest |
Nightly builds of main, least stable | ghcr.io/calandria-dev/calandria:edge |
X.Y.Z is the only one of these that never changes under you; X.Y and
latest are both moving targets. Pin X.Y.Z if you don’t want to re-check
the tag on every upgrade. Use latest only if you’re willing to read the
changelog after every unattended upgrade.
Every past release tag stays pullable indefinitely, so moving between
versions is a matter of re-pinning CALANDRIA_IMAGE. Going backwards takes
one more step than going forwards, because the newer build already migrated
your database; see Rolling back an upgrade.
Running it
docker-compose.yml is the parameterized runner. It
builds from this checkout by default; set CALANDRIA_IMAGE to run the
published image instead.
export CALANDRIA_USER=alice CALANDRIA_PORT=10001 CALANDRIA_RUNTIME=runc
# A) build from this checkout (the default)docker build -t calandria .docker compose -p calandria-alice up -d
# B) or run the published image, nothing to build. :latest is the newest# release; pin :0.3.0 (no leading v) to hold one; :edge is nightly main.# See "Pinning a version" above.export CALANDRIA_IMAGE=ghcr.io/calandria-dev/calandria:latestdocker compose -p calandria-alice pulldocker compose -p calandria-alice up -d --no-build
# open http://127.0.0.1:10001docker compose pull followed by --no-build works around a difference
between Compose versions in how a missing image with a build: stanza
present is handled. The service keeps that stanza, so a bare checkout still
works with no env set.
The container publishes its port on the host’s loopback only. To reach it
from elsewhere, put an authenticated tunnel or reverse proxy in front. The
app hands out a full shell and a bypassPermissions agent: never expose the
port raw.
An HTTPS front also gets you PWA install and the Notification permission,
which browsers only offer in a secure context (HTTPS or localhost). A
plain-HTTP LAN IP gets neither. If your phone is one of your surfaces, reach
the instance through the tunnel hostname, not http://192.168.x.x.
The claude CLI works headless: it prints the OAuth URL and accepts a
pasted code, and the setup wizard drives that flow from the browser.
Antigravity’s subscription sign-in cannot complete in a container: agy
stores its OAuth token in the OS keyring over the D-Bus Secret Service, and
this image ships no keyring daemon. Set GEMINI_API_KEY (or paste a key on
the Antigravity environment in Settings → Models) and the driver points the
CLI at it instead. That bills Google’s API instead of an Antigravity
subscription.
For site-specific CLIs or config layered on the published image, see
examples/overlay/. Keep real overlays in a private
repo, not committed here.
Compose variables. CALANDRIA_USER and CALANDRIA_PORT are required.
Compose fails loudly if you miss them (set CALANDRIA_USER (e.g. alice))
instead of starting a second, empty instance. The -p project name is your
own label, not something the app reads. An existing stack can stay on
-p orch-alice; these docs use -p calandria-alice for new ones.
| Variable | Default | Effect |
|---|---|---|
CALANDRIA_USER | (required) | Names the container, network, and home volume: orch-u-<user>-* |
CALANDRIA_PORT | (required) | Host loopback port the app is published on |
CALANDRIA_IMAGE | calandria:latest (built locally) | Image to run. Point it at a published tag to skip building |
CALANDRIA_RUNTIME | runsc | Container runtime. runsc (gVisor) keeps the untrusted shell and agent off the host kernel. Docker Desktop and colima ship no runsc, so local dev needs CALANDRIA_RUNTIME=runc; runc is not an acceptable runtime for a production, multi-user host |
CALANDRIA_CPUS | 2 | CPU limit passed to the container runtime |
CALANDRIA_MEM | 4g | Memory limit (and matching swap limit) passed to the container runtime |
These six are interpolated by docker compose itself, not read by the app,
so unlike the app’s own env vars (below) they have no ORCH_* fallback: an
existing ORCH_USER=… docker compose up fails loudly instead of starting a
second, empty instance. Rename them in your shell or .env:
sed -i 's/^ORCH_\(USER\|PORT\|CPUS\|MEM\|IMAGE\|RUNTIME\)=/CALANDRIA_\1=/' .envThe resource names these create are unchanged: the home volume is still
orch-u-<user>-home and the network still orch-u-<user>-net. Renaming them
would strand every existing instance’s database, cloned repos, and agent
logins behind a name nothing mounts anymore. Only the mount path moved, from
/home/orch to /home/calandria; the image keeps /home/orch as a symlink
to the new home, so old paths still resolve and new writes land under
/home/calandria.
To rename the volume to match the product anyway, do it while the container is down. Docker has no rename, so this is a copy:
docker compose -p calandria-alice downdocker volume create calandria-u-alice-homedocker run --rm -v orch-u-alice-home:/from -v calandria-u-alice-home:/to alpine \ sh -c 'cd /from && cp -a . /to'# then point the compose `volumes:` stanza at the new name and bring it back upVerify the copy (the database and projects/ are there) before
docker volume rm on the old one. There is no undo.
Upgrading
Take a backup first. It’s one command, needs no downtime, and is the only thing that makes the upgrade reversible: the new build migrates your database on first boot, and there’s no down-migration.
# 1. Snapshot the database while the old version is still the one running.docker exec -u calandria calandria-alice npm run backup -- --out /home/calandria/backups# (running locally instead: npm run backup -- --out /mnt/backups)
# 2. Note the version you are on (it's what you'd roll back to).curl -s localhost:10001/api/version
# 3. Upgrade.export CALANDRIA_IMAGE=ghcr.io/calandria-dev/calandria:0.3.0 # or :latestdocker compose -p calandria-alice pulldocker compose -p calandria-alice up -d --no-buildThe container comes up, runs its schema migrations, and starts serving. Migrations are idempotent, so re-running the same version changes nothing and a database from any older version upgrades in place. Most are additive: new columns get defaults. A few are not. This release drops two columns the provider migration left unread. That is why the build stamps a schema version and an older build refuses the database, and why step 1 matters: migrations are not reversible.
After migrating, the build stamps the database with the schema version it
understands (PRAGMA user_version,
lib/schema-version.mjs). An older build
pointed at that database then refuses to start instead of writing to a
schema it has never seen. The next section covers that refusal.
Upgrading a source checkout
A source checkout upgrades like any other Node app: pull, reinstall, rebuild, restart. Take a backup first, for the same reason as the container path above: the new build migrates your database on first boot, and there’s no down-migration.
npm run backup -- --out <backup dir>git pullnpm cinpm run buildRestart the server once the build finishes: stop the running npm start
process and start it again, or restart whatever process manager or systemd
unit runs it.
Rolling back an upgrade
A rollback is two moves: re-pin the image, and restore the database the old version knew. Doing only the first is the common mistake.
If you re-pin the image alone, the old build finds a database stamped by the newer one and refuses to boot:
Refusing to start: /home/calandria/.calandria/calandria.db was written by a NEWER version of Calandria.
database schema version: 2 this build understands: 1 ...This is a clean failure, not a corrupted instance. The container exits and,
with restart: unless-stopped, keeps retrying; check docker compose logs
to see it. Pick one of two exits:
A. Forward: go back to the version you just came from. Nothing to restore; the database is already the shape that build expects. Use this when the upgrade just surprised you and no data is at stake.
export CALANDRIA_IMAGE=ghcr.io/calandria-dev/calandria:0.3.0 # the NEWER tagdocker compose -p calandria-alice pulldocker compose -p calandria-alice up -d --no-buildB. Backward: pin the old tag and restore the pre-upgrade backup. This is a real rollback: you give up everything that happened after the backup was taken.
# 1. Stop the app. Never swap the database file under a running instance.docker compose -p calandria-alice stop
# 2. Pin the version you are going back to (no leading v).export CALANDRIA_IMAGE=ghcr.io/calandria-dev/calandria:0.2.0
# 3. Restore the database from the backup you took before the upgrade.# Full procedure, including the sidecars and the manifest: "Restore" below.mkdir -p /tmp/rollbacktar -xzf calandria-backup-20260827T221316Z.tar.gz -C /tmp/rollbackcd /tmp/rollback/calandria-backup-20260827T221316ZDBDIR=~/.calandriarm -f "$DBDIR"/calandria.db-wal "$DBDIR"/calandria.db-shmcp db/calandria.db "$DBDIR"/calandria.db
# 4. Bring the old version up.docker compose -p calandria-alice pulldocker compose -p calandria-alice up -d --no-buildcurl -s localhost:10001/api/versionRead Restore before running step 3. It covers the db-dir/ half
(uploads, VAPID key, a persisted API key), agent logins, and three things a
restored instance does that look like faults but aren’t. The backup’s
manifest.json records the snapshot’s userVersion and the app version
that wrote it, so you can check that the tag you just pinned will accept it.
A rollback costs three things:
- Everything since the backup is gone, including turns that ran on the new version. Task branches survive in your project repos, so committed work outlives the rollback; the task rows describing it don’t.
- Review pins live in your repositories. If you use review submissions, back up each project repo with the database as one set; the database alone cannot restore code reviews.
- Worktrees are not rolled back (they aren’t in the default backup). A task whose row came back but whose checkout moved on gets a fresh one cut from its branch on the next turn.
- No backup, and you want the old version anyway? There’s no supported way to down-migrate. Stay on the newer version (A above), or start from a fresh database, backing up the current one first either way.
Update notifications
A pill in the titlebar of every Calandria window announces a newer release when one exists. Click it for a popover with the release notes and, below them, whatever action fits how this server was installed.
The server checks GitHub’s releases feed for calandria-dev/calandria
every six hours, in the server process only: browser tabs never call
github.com. The first check runs 60 seconds after the server’s tickers
start; the result is cached, so a restart shows the last known answer
immediately, with no fresh check to wait on. The request is:
GET https://api.github.com/repos/calandria-dev/calandria/releases?per_page=20User-Agent: calandria/<version>Unauthenticated, with a 10 second timeout, and carrying nothing else about the instance: no name, no address, no usage. A failed check keeps the previous answer, shows nothing new in the titlebar, and Settings → General → Updates reports “Last check failed” with the error.
Set CALANDRIA_UPDATE_CHECK=off to stop the check for this instance
entirely; it also hides the pill and hides the switch in Settings, since
there is then nothing to switch. With the check left on, Settings → General
→ Updates has its own Check for updates switch on top of the env var;
either one off stops the checking.
CALANDRIA_UPDATE_FEED_URL replaces the releases URL, for a fork or an
internal mirror of the feed.
Skip this version, in the popover or in Settings, hides the pill until
a release newer than the skipped one appears. The skip is written to the
instance’s own settings (update_dismissed), so every browser open on that
instance sees the same skip; it is not a per-browser preference.
Behind a proxy. Node’s fetch does not read HTTPS_PROXY. A host that
can only reach the internet through a proxy sees every check fail with
“Last check failed” in Settings and never shows a pill. Set
CALANDRIA_UPDATE_CHECK=off there, or point CALANDRIA_UPDATE_FEED_URL at
a mirror that host can already reach.
Rate limits. Unauthenticated GitHub API calls are capped at 60 an hour per source IP. One call every six hours per instance is far under that on its own, but several self-hosted instances sharing one NAT address share the same 60-per-hour budget. The only symptom of running past it is a pill that stops updating; there’s no distinct error for it beyond “Last check failed.”
Origin-side auth (Cloudflare Access)
If you front an instance with Cloudflare Access, set CF_ACCESS_TEAM_DOMAIN
and CF_ACCESS_AUD. The origin then re-verifies the Access JWT
(Cf-Access-Jwt-Assertion header or CF_Authorization cookie, checked
against the team’s public signing keys and the app’s aud tag) on every HTTP
route and every WebSocket upgrade, including the /pty terminal proxy. No
valid assertion gets a 403. Log out sits at the foot of the settings
section nav, below Setup: it ends the Access session for this instance and
follows the redirect Access hands back. It renders only when there is a
session to end, so nothing appears there in local mode.
Requests get a second check on top of the JWT: if the browser sends an
Origin header, it must match the Host the request was aimed at. The JWT
proves who is calling, not that they meant to call: the CF_Authorization
cookie is SameSite=None by default, so a hostile page can make a logged-in
user’s browser issue a request that the edge attaches a valid assertion to,
for example opening wss://your-host/pty for a shell, or POSTing to a
mutating API route via a CORS-exempt form or text/plain body. WebSocket
upgrades require an Origin; HTTP requests only require that one match if
it’s sent, so an ordinary cross-site link to your instance from an email or
wiki still opens normally.
PUBLIC_BASE_URL isn’t required for this check: it compares the request’s
own two headers. Set it only if your proxy rewrites Host (Cloudflare
Tunnel’s httpHostHeader), which would otherwise make the two disagree. The
pty sidecar repeats the same checks independently, so reaching PTY_PORT
directly grants nothing either.
Unset (the local default), the app has no login, but it still enforces a
browser-origin boundary: it accepts loopback hosts, rejects cross-site
requests, and requires /pty WebSocket upgrades to carry a matching browser
Origin. This stops an unrelated website from driving the local shell and
blocks DNS-rebinding hostnames. PUBLIC_BASE_URL is accepted automatically;
list any other LAN origin explicitly in CALANDRIA_ALLOWED_ORIGINS. This is
still single-user mode, not authentication: never expose it raw to the
internet.
The one Access-mode exception is SERVICE_TOKEN: a shared secret letting
health probes read the documented service-token routes without an Access
JWT (x-service-token header).
Nothing requires you to set a value, but in Access mode something has to
present one: three in-container callers have no Access JWT (the image’s
HEALTHCHECK, the boot restore of managed services, and the stdio MCP
bridge non-Claude agents’ tool calls go through). If CF_ACCESS_* is set
and you leave SERVICE_TOKEN empty,
docker/entrypoint.sh mints a per-boot token
into /tmp/calandria-service-token, which the HEALTHCHECK reads (a
healthcheck runs as a separate exec with the image’s environment, so the
file is the only way a generated token reaches it). Supply your own token
when a monitor outside the container needs to poll. Running bare Node
behind Access with no token, server.js warns loudly at startup instead.
CALANDRIA_FLEET_TOKEN is a second, optional secret for the same read-only
routes, shared fleet-wide so one dashboard can poll many boxes without
learning each one’s private SERVICE_TOKEN. It is not accepted on the
mutating internal agent-tool endpoints. Unset (the default), it grants
nothing.
Exposing the MCP endpoint
CALANDRIA_MCP_TOKEN turns on <base URL>/api/mcp, an MCP endpoint that serves Calandria’s
task tools to agents running outside Calandria. Unset (the default), the path answers 404.
See External MCP endpoint for the tools and client
configuration.
- Generate a token:
openssl rand -hex 32. - Set it as
CALANDRIA_MCP_TOKENand restart. - Point the client at
<base URL>/api/mcpwith the headerAuthorization: Bearer <token>.
The token is separate from SERVICE_TOKEN; SERVICE_TOKEN and Access JWTs do not work on
this path. middleware.ts checks the bearer token before the browser-origin rules in both
modes, so a client on another host works without adding its Host to
CALANDRIA_ALLOWED_ORIGINS. A wrong or missing token gets a 401 whose body names the reason: no
Authorization header, an empty bearer token, or a token that does not match. The server logs the
same reason as [mcp] refused /api/mcp request with 401: ..., at most once a minute per reason.
In Access mode, the Cloudflare edge in front of your tunnel still applies its own policy to
/api/mcp. Give the client a path that Access lets through, such as an Access service token
or a bypass policy for /api/mcp, or have it reach the instance directly on the LAN.
Connecting the desktop app
The desktop app (docs/DESKTOP_APP.md) keeps a list of
instances and points its window at one of them. This computer is the
server it runs itself. Every other entry is a server you are already
hosting, reached over one of three transports. Add one from “Instance → Add
instance…” in the app menu or the tray; the address field decides the
transport.
Nothing on the server has to be installed, enabled, or configured for the desktop app specifically. The requirements below are the ones a browser on the same machine would already have.
| Transport | Address you type | What the instance needs |
|---|---|---|
| Direct URL behind Cloudflare Access | https://calandria.example.com | CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD set, and the hostname reachable |
| LAN origin in local mode | http://192.168.1.50:3000 | that exact origin in CALANDRIA_ALLOWED_ORIGINS (or as PUBLIC_BASE_URL), and CALANDRIA_HOSTNAME widened past loopback |
| SSH port-forward | ssh://build-box:3000 | an SSH login that works non-interactively, and the server on the remote’s loopback |
Direct URL behind Cloudflare Access. The app window is a browser, so the Access login page opens in it and completes there. Each instance gets its own persistent cookie jar, so two instances behind the same Access team do not share an assertion, and “Sign out” on an instance deletes that jar. This is the only transport where the server is reachable from outside your network, and it is the one to use for that: Access does the authentication.
If your identity provider refuses logins from an embedded browser, check that before choosing this transport. Electron’s default user agent is usually accepted.
LAN origin in local mode. Local mode has no login, so the origin gate is
the whole boundary and it defaults to loopback only. To reach an instance
from another machine on your network, widen two different things:
CALANDRIA_HOSTNAME (which interface the server binds) and
CALANDRIA_ALLOWED_ORIGINS (which browser origins it accepts). The desktop
app loads its page from the remote origin, so Origin equals Host and the
request passes exactly when a browser tab on that URL would. Attaching to a
plain LAN instance has the same trust level as opening it in a browser:
anyone who can reach the port gets a shell. Do not do this on a network you
do not control, and never on the internet.
SSH port-forward. The app spawns your own ssh binary with a local
forward and attaches to http://127.0.0.1:<local port>. The server needs no
configuration for this: it stays bound to loopback on the remote, which is
what local mode already trusts. SSH is the credential, so your config,
agent, jump hosts, ControlMaster sockets, and hardware keys all apply.
The forward runs with BatchMode=yes, since a GUI has no terminal to answer
a prompt in. A host that would ask for a password or a 2FA code fails the
attach with ssh’s own error instead of hanging. Set up a key or a
ControlMaster socket first, and confirm ssh -o BatchMode=yes <host> true
succeeds before adding the instance.
Two limits of this transport. A managed service exposed on a
<slug>--<host> hostname is not reachable through a single-port forward, so
those links need a forward of their own or a browser that can reach the
remote directly. And an instance you are not currently attached to
contributes nothing to the app’s badge count: reaching it means holding an
SSH connection open to a machine you are not looking at. url instances
have no such cost and are watched whether or not the window is on them.
Signing in when the login page needs a passkey. The Cloudflare Access
transport above works because the app window is a browser and the login
completes inside it. That breaks down for an identity provider in front of
your own reverse proxy that wants a passkey, a security key or a phone-based
ceremony: Electron has no WebAuthn implementation, so the provider hands the
ceremony off to your system browser, and the app’s window and that browser are
two different cookie jars. For that case, configure the instance’s sign-in
settings in “Manage instances…” instead: the whole login runs in your real
browser and a token comes back to the app over a loopback redirect, or you
supply a credential you already hold as request headers. See
docs/DESKTOP_APP.md for how it
works and which case it is for.
Naming an instance
CALANDRIA_INSTANCE_NAME is a human name for an instance (“Lab”, “Build
box”). It is optional and cosmetic, and it is the only thing on the server
that acknowledges being one of several:
- The web app puts it in the document title and at the root of its breadcrumb, so two browser tabs on two instances are told apart without clicking either.
GET /api/versionreports it asinstanceName(null when unset).- The desktop app reads it off that handshake and offers it as the name for an instance you add by URL without typing one. A name you type is yours and is never overwritten.
- With more than one instance saved, the desktop app appends it to every OS notification that instance raises, and clicking that notification switches to it.
Configuration
Every per-instance value is an env var with a documented default. One env
set relocates an instance (fresh container, different user, different
ports) with zero code edits. .env.example has the same
list in copyable form. Export variables in the environment that launches
npm run dev / npm start: server.js and pty-server.js are plain Node
and read them before Next boots, so a .env file alone doesn’t cover
PORT, CALANDRIA_HOSTNAME, or PTY_*.
The desktop app is the one exception, and it’s desktop-only: server.js and
pty-server.js still read only process.env, exactly as above, so this is
unchanged for every self-hosted deployment. The desktop app has no launcher
script sourcing anything in front of it: a Finder/Dock/Login Item launch
hands it launchd’s own minimal environment, so it reads $CALANDRIA_ENV_FILE
(default ~/.config/calandria/env) as its own substitute for the launcher
this section assumes. See
DESKTOP_APP.md for the
file format and how it layers with the app’s own env.
Every variable below also answers to an older ORCH_* spelling (a uniform
prefix swap: ORCH_FOO → CALANDRIA_FOO). An existing .env, systemd
unit, or docker run -e keeps working untouched: a CALANDRIA_* value wins
if both are set, an empty value counts as unset on both sides, and the
server prints one boot-time warning naming whichever old names are still in
use. Move a self-hosted .env, systemd unit, or compose file over on your
own schedule. The compose-only variables in the Docker section above are the
one exception: docker compose interpolates them itself with no aliasing
mechanism, so they need the sed rename shown there instead.
Configure model providers in Settings → Models. Select Add provider to add a LiteLLM gateway, Ollama, LM Studio, or a custom endpoint. The provider variables in the table are first-boot seeds for a LiteLLM or local provider row. If a row of that type already exists, the stored row wins, and the startup log reports that it ignored the environment input.
| Variable | Default | Effect |
|---|---|---|
PORT | 3000 | Port of the single public origin (Next.js + /pty proxy) |
CALANDRIA_HOSTNAME | 127.0.0.1 | Bind address of the app server. Loopback by default: a local instance is unauthenticated, and the origin gate is a header check a LAN client can forge. Widen it only behind CF_ACCESS_*. Bare HOSTNAME is not read (shells and container runtimes inject it). The image sets CALANDRIA_HOSTNAME=0.0.0.0, correct for a container published on the host’s loopback |
CALANDRIA_LOG_FORMAT | text | text is the human-readable [component] message key=value form; json emits one object per line (ts, level, component, msg, plus that line’s fields) for a collector. server.js, pty-server.js, and the app each read it independently, so set it for all three. See Reading the logs |
CALANDRIA_CLAUDE_DEBUG_DIR | (unset) | Directory for per-turn Claude CLI debug logs (<task>-g<generation>-<timestamp>.log, the CLI’s own --debug-file). Unset writes none. Turn it on to catch a Calandria tool call the CLI answers itself (“interrupted before a result was received”); the logs are verbose and never pruned, so clear the directory after |
PTY_PORT | 3001 | Port of the node-pty terminal sidecar |
PTY_HOST | 127.0.0.1 | Bind address of the sidecar and the proxy’s upstream. Keep it on loopback; the browser never connects directly, since server.js proxies /pty to it |
CALANDRIA_PTY_SHELL | (empty) | The shell every terminal tab spawns. Empty falls back to $SHELL, then a platform default (POSIX: first of /bin/zsh, /bin/bash, /bin/sh that exists; Windows: pwsh.exe/powershell.exe on PATH, else %COMSPEC%). Set this if the terminal drawer can’t spawn a shell, or to get a different one than your login shell |
CALANDRIA_INSTANCE_NAME | (empty) | A human name for this instance (“Lab”, “Build box”). Shown in the document title and the app’s breadcrumb, reported on GET /api/version, and used by the desktop app as the default name for an instance added by URL. Trimmed, capped at 60 characters |
PUBLIC_BASE_URL | (empty) | The origin you reach the app on (e.g. https://calandria.example.com behind a tunnel); the client builds its ws(s):// terminal URL from it. Empty means the browser’s own origin, which works for any single-hostname deployment. Set it if your proxy rewrites Host, which would otherwise make the origin gate’s Origin vs Host check disagree |
CALANDRIA_ALLOWED_ORIGINS | (empty) | Exact comma-separated http(s) origins allowed in no-login local mode, for intentional LAN or reverse-proxy access. Loopback origins and PUBLIC_BASE_URL are already accepted. Not a substitute for authentication |
VAPID_SUBJECT | (derived) | Contact for the browsers’ push services (Web Push VAPID subject): a mailto: or https: URL. Defaults to PUBLIC_BASE_URL when that’s https, else mailto:admin@localhost. iOS rejects localhost with 403 BadJwtToken, so set a real https origin or mailto: for iOS push |
VAPID_PRIVATE_KEY | (minted) | Base64url raw P-256 scalar signing every push. Empty means minted on first use and kept at <CALANDRIA_DB_DIR>/vapid.json; subscriptions are bound to it, so back it up with the database |
CALANDRIA_PTY_ALLOW_REMOTE | (off) | Set 1 to let the pty sidecar accept off-machine peers. Otherwise it requires a loopback peer, since server.js proxies to it from the same host. Only for a split deployment; anything that reaches the sidecar gets a shell |
CF_ACCESS_TEAM_DOMAIN | (empty) | Cloudflare Zero Trust team domain (e.g. your-team.cloudflareaccess.com); see above |
CF_ACCESS_AUD | (empty) | The Access application’s aud tag the JWT must carry (comma-separable) |
SERVICE_TOKEN | (empty) | Shared secret for the health/version/usage/metrics routes and the in-container callers (health probe, service restore, agent-tool bridge); see above. The image mints a per-boot one under Access if empty |
CALANDRIA_FLEET_TOKEN | (empty) | Optional fleet-wide read token for the same read-only routes (/api/version, /api/instance/usage, /api/instance/metrics, GET /api/instance/scheduler), so one dashboard can scrape many instances with one secret. Never accepted on mutating endpoints |
CALANDRIA_MCP_TOKEN | (empty) | Turns on the external MCP endpoint at /api/mcp; every request must send it as Authorization: Bearer <token>. Separate from SERVICE_TOKEN. Unset, the endpoint answers 404. See “Exposing the MCP endpoint” above |
CALANDRIA_DB_DIR | ~/.calandria | Directory holding calandria.db (SQLite app data). Absolute path; created on first run |
CALANDRIA_DB_LOCK | on | The single-instance boot lock. off lets a second process start against a database another one already owns; unsupported, and exactly the corruption the lock exists to prevent. See One process per database below |
CALANDRIA_DB_LOCK_WAIT_MS | 10000 | How long boot retries the lock before giving up. Covers a predecessor that is still shutting down; a crashed one releases instantly |
CALANDRIA_WORKTREES_DIR | ~/.calandria/worktrees | Where per-task git worktrees are created. Must live outside any project repo |
CALANDRIA_PROJECTS_DIR | ~/projects | Where Clone from GitHub puts cloned repos |
CALANDRIA_MAX_UPLOAD_MB | 25 | Largest single attachment, on a chat message or a task itself. Any file type may be attached; it is staged under <CALANDRIA_DB_DIR>/uploads/<taskId> and only its path goes into the message or the task’s description, so the cap bounds disk and the server’s heap, not the model’s context. The New task dialog has no task id yet, so its attachments stage under <CALANDRIA_DB_DIR>/uploads/_drafts/<draft>/ and move into the task’s own directory once the task is created; the Edit task dialog and the agent tools’ attachments parameter write straight into that same per-task directory |
CALANDRIA_BACKUP_DIR | <CALANDRIA_DB_DIR>/backups | Where npm run backup writes its archives. Read by scripts/backup.mjs, not by the app; point it at a different volume than the one being backed up. See Backup & restore below |
CALANDRIA_SERVICE_PORT_BASE | 4300 | Base of the deterministic per-project port block. Each project is assigned base + slot at creation, injected as PORT into its supervised services and PTY |
CALANDRIA_SERVICE_LOG_LINES | 1500 | Per-service in-memory log ring buffer (lines) kept for the Services drawer |
CALANDRIA_SERVICE_HOSTS | (off) | Set 1 to serve each service on a public hostname <slug>--<appHost> with per-service visibility (private / shared link / public). Also needs PUBLIC_BASE_URL plus wildcard DNS/TLS |
CALANDRIA_REVIEW_REQUESTS | (off) | Set 1 to let agents open review requests. Gates creation only; the Work Center, stored reviews and the completion guards are always active. See Review submissions before enabling, because it limits how far you can roll back |
CALANDRIA_FEATURE_SERVICES | 1 (on) | The managed-services feature (Services drawer, supervisor, persisted registry with boot auto-restart plus orphan reaping). Set 0 to disable |
CLAUDE_CLI_PATH | ~/.local/bin/claude | Path to the logged-in claude CLI (pinned since Next’s server may run with a trimmed PATH). On Windows: %USERPROFILE%\.local\bin\claude.exe, then PATH (point at a real .exe, not an npm .cmd shim) |
CALANDRIA_LOCAL_MODEL_BASE_URL | http://localhost:11434 | First-boot seed for one local provider row. Port 11434 creates an Ollama row, port 1234 creates an LM Studio row, and any other port creates a custom OpenAI-compatible row. Leaving the variable unset creates no row. A Docker instance reaches the host’s Ollama at http://host.docker.internal:11434. See Local models |
CALANDRIA_LITELLM_BASE_URL | (unset) | First-boot seed for one LiteLLM provider row. This address is required for the seed. The companion variables below populate the same row. See LiteLLM gateway |
CALANDRIA_LITELLM_KEY | (unset) | First-boot secret seed for the LiteLLM row created by CALANDRIA_LITELLM_BASE_URL. The seed stores it in the provider secrets file with mode 0600 |
CALANDRIA_LITELLM_ADMIN_KEY | (unset) | First-boot admin-key seed for the LiteLLM row created by CALANDRIA_LITELLM_BASE_URL. Leaving it unset disables per-task virtual keys and exact spend reconciliation |
CALANDRIA_LITELLM_MCP | on | First-boot seed for whether the LiteLLM row created by CALANDRIA_LITELLM_BASE_URL permits hosted MCP servers |
CALANDRIA_LITELLM_KEY_TIMEOUT_MS | 8000 | First-boot timeout seed for the LiteLLM row created by CALANDRIA_LITELLM_BASE_URL. It bounds each /key/generate, /key/delete, or /key/info call |
CALANDRIA_GH_BIN | (auto-resolve) | Path to the GitHub CLI (gh). Empty means bare gh if the server’s PATH resolves it, else a probe of the usual install dirs (linuxbrew/Homebrew, /usr/local/bin, snap, ~/.local/bin; Windows: winget Links, %ProgramFiles%\GitHub CLI, scoop shims). The server never reads a shell profile, so set this if the probe misses your gh |
GEMINI_API_KEY | (unset) | Google AI Studio key for the Antigravity driver, used when agy’s own subscription sign-in can’t complete (for example inside a container with no keyring). Bills Google’s API instead of an Antigravity subscription |
CALANDRIA_PR_STALE_MS | 60000 | How long a task’s PR state counts as fresh. Opening a task, the chip’s Refresh button and the create-PR trigger all skip gh pr view inside this window |
CALANDRIA_PR_POLL_MS | 300000 | How often the background sweep re-reads tasks whose PR is still open. 0 disables the sweep, leaving the on-open and explicit-Refresh triggers. The sweep stops itself when no PR is open and skips a pass when no browser tab is watching |
CALANDRIA_PR_POLL_BATCH | 5 | Most PRs refreshed per sweep (one gh pr view each, oldest-synced first) |
CALANDRIA_ISSUE_REPO | calandria-dev/calandria | Repository (owner/name) the agent’s report_issue tool drafts Calandria bug reports and feature requests into. The tool files nothing on its own: it drafts, searches that repo for duplicates and renders a card the user edits and sends, so this only decides where a click would go. Point it at a fork to keep reports in-house, or set off to drop the tool from every agent’s prompt |
CALANDRIA_CI_LOG_TAIL_LINES | 200 | Lines of a failed job’s log the Fix CI button seeds its turn with, per failing check. gh run view --log-failed already drops the green steps, but only the end of a failing suite says what broke |
CALANDRIA_PERMISSION_PROMPT_TIMEOUT_MS | 14400000 (4h) | How long a permission prompt parks waiting for a decision while at least one browser tab is watching, before it denies the call. 0 parks indefinitely, like an unanswered question card |
CALANDRIA_PERMISSION_UNATTENDED_MS | 45000 (45s) | The same deadline when nobody is watching: an auto-started task, or any turn running while the app is closed. 0 always uses the attended timeout above |
CALANDRIA_ALLOW_API_KEY_ENV | (off) | Keep an ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY found in the launch environment instead of stripping it at boot. Turns then bill per-token instead of using a connected subscription login |
CALANDRIA_SCHEDULER | on | Master switch for the schedule ticker. off stops this instance from ever starting scheduled work on its own; the worktree sweep and retention prune still run on the same ticker if either of those is on |
CALANDRIA_SCHEDULE_TICK_MS | 30000 | How often the ticker wakes to check for due firings |
CALANDRIA_SCHEDULE_CATCHUP_MS | 14400000 (4h) | How late a missed firing may still run. Past this it’s recorded missed, never run; 0 disables catch-up entirely |
CALANDRIA_SCHEDULE_PROBE_MS | 20000 | Bound on the fire-time slash-command check, which spawns the agent CLI inside the ticker’s single-flight sweep. An unbounded check would wedge every schedule on the instance |
CALANDRIA_RETENTION | on | Master switch for the retention sweep of finished tasks’ database records. off/0/false/no keeps everything forever |
CALANDRIA_RETENTION_DAYS | 180 | How long a finished task keeps its own record: transcript, review comments, /clear-retired sessions, and uploaded attachments. 0 keeps them forever |
CALANDRIA_USAGE_RETENTION_DAYS | 400 | How long the spend rows (task_usage, task_merges, internal_usage) live. Longer than the window above because Insights reads 180 days back and needs the same width again for prior-period deltas. 0 keeps them forever |
CALANDRIA_RETENTION_SWEEP_MS | 21600000 (6h) | How often the retention sweep runs. Also paces the worktree sweep and the disk-usage check below, on the same ticker |
CALANDRIA_RETENTION_VACUUM | (off) | Run a full VACUUM after a sweep that deleted rows, to shrink calandria.db itself. A checkpoint alone reclaims the WAL but not the main file; VACUUM rewrites the whole database under a write lock, so it’s opt-in |
CALANDRIA_WORKTREE_RETENTION | (off) | Master switch for the worktree sweep, which reclaims git worktrees of finished, cold tasks on a shorter clock than the table prune above. Set 1/on/true/yes to enable |
CALANDRIA_WORKTREE_RETENTION_DAYS | 14 | How long a finished task keeps its git worktree once the sweep is on. 0 keeps them forever. The sweep never touches a worktree with uncommitted edits or commits the base branch hasn’t absorbed, however old, and it never deletes the branch |
CALANDRIA_WORKTREES_DISK_WARN_GB | 20 | Warn in the server log, and on GET /api/instance/scheduler, when the worktrees directory crosses this size. Runs whether or not the sweep above is enabled. 0 disables it |
CALANDRIA_METRICS_SIZE_TTL_MS | 60000 (1m) | How long /api/instance/metrics reuses one measurement of the worktrees directory. That measurement runs du over every task checkout on the box, so a short scrape interval would otherwise walk it repeatedly; raise this if you carry many large worktrees. 0 measures on every scrape |
Two variables above (CALANDRIA_ENV_FILE, and the desktop app’s own instance
list) are read by the desktop shell only, not by server.js; see
DESKTOP_APP.md for those.
Example: relocate an instance entirely via env.
PORT=8080 PTY_PORT=8081 \PUBLIC_BASE_URL=https://calandria.example.com \CALANDRIA_DB_DIR=/data/calandria \CALANDRIA_WORKTREES_DIR=/data/worktrees \CLAUDE_CLI_PATH=/usr/local/bin/claude \npm startUpgrading from the pre-rename default paths
CALANDRIA_DB_DIR and CALANDRIA_WORKTREES_DIR used to default to
~/.zen-orchestrator and ~/.agent-orchestrator/worktrees. Nothing moves
automatically: a worktree is registered by absolute path in its parent
repo’s .git/worktrees/<id>/gitdir, so relocating one needs a
git worktree repair run inside every affected project repo, not a file
move.
Instead the resolver falls back. With CALANDRIA_DB_DIR unset, if
~/.calandria holds no database but ~/.zen-orchestrator/orchestrator.db
exists, the app keeps using the old path and boot prints a hint line naming
it and where to move it. Inside an explicit CALANDRIA_DB_DIR,
calandria.db is preferred, with an existing orchestrator.db there as the
fallback (checked as a file, not a directory, so a container entrypoint
pre-creating an empty ~/.calandria never strands existing data). Worktrees
follow a simpler rule: a populated legacy ~/.agent-orchestrator/worktrees
is kept as-is, and only an empty one is abandoned for the new default.
To migrate on your own schedule:
# Move a pre-rename install to the new default location.# Stop the app first: copying a live SQLite database mid-write corrupts it.mkdir -p ~/.calandriamv ~/.zen-orchestrator/orchestrator.db ~/.calandria/calandria.dbmv ~/.zen-orchestrator/orchestrator.db-wal ~/.calandria/calandria.db-wal 2>/dev/null || truemv ~/.zen-orchestrator/orchestrator.db-shm ~/.calandria/calandria.db-shm 2>/dev/null || true# The boot lock is a pure mutex holding no data: delete it, don't move it.rm -f ~/.zen-orchestrator/orchestrator.lock.*# Anything else the app keeps beside the database (API keys, VAPID keys, uploads):mv ~/.zen-orchestrator/* ~/.calandria/ 2>/dev/null || true# Then start the app. The boot hint line stops printing once nothing legacy is left.Move the -wal/-shm files together with the database, or checkpoint them
away first: a stale -wal left behind next to a moved .db loses the most
recent writes. This recipe doesn’t cover per-task worktrees: either leave
CALANDRIA_WORKTREES_DIR pointing at the old directory, or relocate it
yourself and run git worktree repair <new-path>/<task-id> inside each
affected project repo.
Advanced settings (Settings → Advanced)
Settings → Advanced manages environment variables for this instance from the
browser instead of a launcher’s env or a .env file. It writes one file,
advanced-environment.json, next to calandria.db in CALANDRIA_DB_DIR, and
covers two separate scopes:
- App: server-process settings such as the permission and retention
timers. A change here needs a server restart to take effect; Settings shows
a “Restart required” banner until one happens, and the API’s
restartRequiredflag stays accurate across a delete too. Restarting the desktop app is one click (seeDESKTOP_APP.md); a self-hosted or remote server needs its own manual restart (docker restart,systemctl restart, or however it’s supervised). - Agent sessions: variables agent turns run under (the Codex transport, approval policy and sandbox controls, plus any custom name). A change here takes effect on the next turn, including a resumed one; a turn already running keeps the environment snapshot it started with.
Precedence. For the app scope: built-in defaults, then a saved row, then
whatever the launch environment (or the desktop environment file) already
sets for that name or its ORCH_* alias. A launch-environment value always
wins; Settings marks a shadowed row “Overridden by host” and deleting it has
no effect until the host value is also removed, since the saved row was never
the active one. For the agent scope, existing task, project, and run-default
precedence for model, permission and sandbox choices is unchanged; these
environment controls are a fallback layer under all of that, not a way to
override a task’s own configured permission mode.
Deleting a row restores whatever value the variable would otherwise have: the inherited launch environment for an app-scope name, or that variable’s own default for an agent-scope one. Nothing else changes as a side effect.
Reserved and provider-owned names. Deployment, identity, storage and
network inputs (PORT, PTY_*, NODE_ENV, CALANDRIA_DB_DIR,
CALANDRIA_HOSTNAME, and the rest of the table in Configuration above),
plus task/turn identity and bridge internals on the agent side, are rejected
outright, under a custom name too. Provider routing, model keys and billing
credentials stay in Settings → Models; Advanced only points at that page for
them; it is not a second place to store a provider credential.
Values are literal. A saved value is used exactly as typed: quotes,
$, backticks, and newlines all pass through unevaluated, and an empty
string is a value distinct from having no saved row at all. Nothing here ever
runs a shell.
Secrets. A row marked Secret hides both its name and its value in the
list, shown only as an opaque label like Secret variable · a1b2; the list
API redacts the same way, so nothing recovers the plaintext by reading the
network response either. Editing a secret can rename it or replace its value
without retyping the other; leaving a field on Keep does not resend the old
value at all. Turning Secret off requires an explicit confirmation, since
doing so reveals the name and value in the list from then on.
Disk permissions. advanced-environment.json is written owner-only: POSIX
mode 0600, or on Windows an ACL granting only the running account
(icacls <file> /inheritance:r /grant:r <owner>:(R,W), the same rule
lib/secretFile.ts applies to provider credentials). This is a control over
Calandria’s own read/write paths, not disk encryption or an OS-level secret
store: another process running as the same account can still read the file,
the same way it could read any other file that account owns.
The approval gate. An agent can propose a change through its own tools, but every create, rename, value change, delete, or secret-flag flip parks the turn on a one-time Allow once / Deny card in the transcript, with no “always allow” option. This card is mandatory: a task’s bypass/full-access permission mode, a remembered Bash rule, and a trusted MCP server all still stop and wait for it, and a secret’s plaintext, when the human types one on the card, is sent straight to the server and never appears in the tool’s arguments, the transcript, or an event.
Recovering from a malformed saved file. A corrupted or unreadable
advanced-environment.json blocks every write until it is fixed, and Settings
shows a diagnostic instead of the tables, but the server still boots (with no
saved app overlay) rather than refusing to start. To recover:
# Stop the server first: don't edit the file while something has it open.mv ~/.calandria/advanced-environment.json ~/.calandria/advanced-environment.json.bad# Start the server again. Settings now shows an empty store; re-add any# rows you need from the file you just moved aside, or repair its JSON by# hand and move it back before restarting once more.(Substitute your actual CALANDRIA_DB_DIR above.) Moving the bad file aside
rather than deleting it keeps whatever was in it recoverable; nothing in
Calandria repairs it automatically, since a best-effort JSON fixup could
silently apply half of a broken edit.
Backup & restore
npm run backup (scripts/backup.mjs) takes a hot
backup: run it with the app up, no downtime.
Never cp a live database
calandria.db runs in WAL mode, so recent transactions live in
calandria.db-wal until a checkpoint folds them back. Copying the .db
alone gives you a file that’s silently missing the newest work; copying the
pair mid-write can tear it outright.
The script runs VACUUM INTO instead: one read transaction that writes a
self-contained, already-checkpointed copy with no -wal/-shm sidecars to
move. It then runs PRAGMA integrity_check on the snapshot before calling
the backup done (tests/backup.test.ts pins this against a naive copy of
the same moment).
The backup connection is read-only and takes no application lock: the
single-instance boot mutex is a separate *.lock.db file, so an
out-of-band reader can work while the app owns the database. A backup never
stops the app from booting.
What state lives where
| What | Where | In the backup |
|---|---|---|
| Projects, tasks, transcripts, summaries, usage, schedules, runbooks, permission rules | <CALANDRIA_DB_DIR>/calandria.db (+ -wal/-shm) | db/ (one snapshot, no sidecars) |
| Chat and task attachments (any file type) | <CALANDRIA_DB_DIR>/uploads/<taskId> (plus _drafts/<draft> for an unsaved New task dialog) | db-dir/ |
| Web Push signing key | <CALANDRIA_DB_DIR>/vapid.json | db-dir/ |
| A persisted API key (only if you used the wizard’s key path) | <CALANDRIA_DB_DIR>/anthropic-api-key, openai-api-key | db-dir/ |
| Boot mutex | <CALANDRIA_DB_DIR>/*.lock.db, *.lock.json | excluded: a pure lock holding no data; restoring one restores a stale claim |
| Agent CLI logins | ~/.claude.json, ~/.claude/.credentials.json, ~/.claude/settings.json, ~/.codex/auth.json, ~/.codex/config.toml, ~/.gemini/antigravity-cli/settings.json | agent-login/home/… (--no-logins to skip) |
| Per-task git worktrees | CALANDRIA_WORKTREES_DIR (default ~/.calandria/worktrees) | opt-in (--worktrees) |
| Cloned project repos | CALANDRIA_PROJECTS_DIR (default ~/projects) | opt-in (--projects) |
| Your own repos | wherever you told the project they are | never (they’re yours) |
Antigravity’s subscription token is not in that set: it lives in the OS
keyring, not in a file, so a restored instance signs in again (or
uses GEMINI_API_KEY, which Calandria stores in its own database and the
database backup does carry).
db-dir/ is captured by exclusion (everything in the DB dir that isn’t a
SQLite file, the lock pair, the backup directory, or a nested worktrees
dir), so anything added beside the database later is picked up
automatically.
The last two rows are opt-in: they are reconstructible (a worktree is a checkout of a branch already in the project repo, and a clone is a clone), and including them by default would turn a nightly backup into a disk problem. Skipping them only costs a task’s uncommitted working-tree edits.
Docker vs local. In the container, everything above lives in one named
volume mounted at /home/calandria. Running locally, the same state is
spread across your $HOME (~/.calandria, ~/projects, ~/.claude,
~/.codex), and only the env vars say where; the manifest records the
resolved paths.
The database file name is resolved, not assumed: a fresh install writes
calandria.db, while an install that predates the rename keeps
orchestrator.db and is never migrated
(above). The script backs
up whichever database your instance is actually using, under its real name.
Hot backup
npm run backup # -> <CALANDRIA_DB_DIR>/backups/calandria-backup-<UTC>.tar.gznpm run backup -- --out /mnt/backups # somewhere that isn't the disk you're backing updocker exec -u calandria calandria-alice npm run backup -- --out /home/calandria/backups| Flag | Effect |
|---|---|
--out DIR | Where to write. Default CALANDRIA_BACKUP_DIR, else <CALANDRIA_DB_DIR>/backups |
--worktrees | Also archive per-task worktrees (large, slow) |
--projects | Also archive cloned project repos (large, slow) |
--no-logins | Omit the agent CLI credentials |
--no-archive | Leave the staging directory instead of tarring it |
--quiet | Print only the resulting path on stdout |
The archive is a single .tar.gz holding manifest.json, db/, db-dir/,
and (unless skipped) agent-login/. The manifest records the format and
app version, resolved source paths, the snapshot’s SHA-256 and
user_version, and row counts, enough to tell two backups apart and
reconcile absolute paths on restore. On a 37 MB database with the app
running, the run took 1.2 s and produced an 8.5 MB archive.
It contains credentials. The file is written 0600 on POSIX. On
Windows a POSIX mode is a no-op, so the file inherits the ACL of the
directory it lands in. Put it somewhere private.
Nightly, with your own retention (there is no built-in pruning of old archives):
17 4 * * * cd /opt/calandria && /usr/bin/npm run backup -- --quiet --out /mnt/backups >/dev/null27 4 * * * find /mnt/backups -name 'calandria-backup-*.tar.gz' -mtime +14 -deleteCold backup (the alternative)
To skip all of the above, stop the container and copy the volume: the app is down, nothing is mid-write, and a plain copy (WAL included) is correct.
docker compose -p calandria-alice stopdocker run --rm -v orch-u-alice-home:/from -v /mnt/backups:/to alpine \ tar -czf /to/calandria-volume-$(date -u +%Y%m%dT%H%M%SZ).tar.gz -C /from .docker compose -p calandria-alice startThis captures worktrees and project clones too, so it’s much larger and costs downtime. Reach for it when you want a byte-for-byte image of the whole instance, not just its state.
Restore
This procedure covers a hot backup restored into a scratch directory, with a verification boot afterward.
-
Stop the app. A restore that races a running instance replaces the database file out from under it; the boot lock can’t save you from that.
Terminal window docker compose -p calandria-alice stop # or: systemctl stop calandria -
Unpack somewhere scratch, and read the manifest before overwriting anything.
contents.dbnames the file;source.*says where it came from.Terminal window mkdir -p /tmp/restore && tar -xzf calandria-backup-20260827T221316Z.tar.gz -C /tmp/restorecd /tmp/restore/calandria-backup-20260827T221316Z && cat manifest.json -
Put the database back. Copy the snapshot to
<CALANDRIA_DB_DIR>and remove any stale sidecars beside it: the snapshot is self-contained, and an old-walnext to a new.dbis the one way to lose data on an otherwise successful restore.Terminal window DBDIR=~/.calandria # whatever CALANDRIA_DB_DIR resolves torm -f "$DBDIR"/calandria.db-wal "$DBDIR"/calandria.db-shmcp db/calandria.db "$DBDIR"/calandria.dbcp -a db-dir/. "$DBDIR"/ # uploads, vapid.json, any API keyIf the backup’s
contents.dbisdb/orchestrator.db, this is the moment to leave the old name behind: copy it tocalandria.dbinstead. Nothing but the app’s storage resolver cares about the name, so there’s no other reference to fix up. -
Put the agent logins back (skip if the target is already logged in):
Terminal window cp -a agent-login/home/. ~/ -
Start the app and verify. For a verification boot against a copy, point
CALANDRIA_DB_DIRat the scratch directory and setCALANDRIA_SCHEDULER=offandCALANDRIA_FEATURE_SERVICES=0first. Otherwise the restored instance fires every schedule that looks missed and restarts every managed service in its registry.Terminal window CALANDRIA_DB_DIR=/tmp/restore/data CALANDRIA_WORKTREES_DIR=/tmp/restore/worktrees \CALANDRIA_SCHEDULER=off CALANDRIA_FEATURE_SERVICES=0 PORT=4318 PTY_PORT=4319 npm startcurl -s localhost:4318/api/projects | jq 'length'
Expect three things from a restored instance; none are faults:
- In-flight turns come back interrupted. The snapshot captures whatever was running at that instant. The first boot’s crash recovery clears running flags, queued follow-ups, unanswered permission cards, and in-flight schedule runs. Ask those tasks to continue; the transcript and session lineage are intact.
- Absolute paths come back verbatim.
projects.repo_pathandtasks.worktree_pathare absolute. Restoring onto the same layout (the container case, everything under/home/calandria) needs nothing. Restoring onto a different layout means editingprojects.repo_pathto point at the repos’ new home; worktrees self-heal, since every launch path re-cuts a missing one. - Worktrees you didn’t archive are gone, but that’s recoverable. A task
whose checkout is missing gets a fresh one cut from its branch on the
next turn. Uncommitted edits sitting in the old worktree aren’t in the
backup, the argument for
--worktreesif you run tasks that idle for days with work in progress.
Metrics
GET /api/instance/metrics serves
Prometheus text exposition: a handful of hand-rolled series
covering two questions a running instance can’t otherwise answer from
outside: is it doing work, and is it eating the disk? It’s always on.
Auth uses the same read-only service-token exemption as /api/version and
/api/instance/usage. In no-login local mode, a loopback scrape needs
nothing. Under Cloudflare Access, a scraper has no JWT, so it presents
SERVICE_TOKEN or CALANDRIA_FLEET_TOKEN (one secret for a dashboard
polling every box).
# local mode, from the hostcurl -s localhost:3000/api/instance/metrics
# behind Accesscurl -s -H "x-service-token: $CALANDRIA_FLEET_TOKEN" \ https://calandria.example.com/api/instance/metrics| Series | Type | What it is |
|---|---|---|
calandria_build_info{version,sha} | gauge | Always 1; read the labels. The same provenance /api/version reports, so a change in behavior can be lined up against a deploy |
calandria_process_start_time_seconds | gauge | When this process booted. The counters below reset here; graph it alongside them |
calandria_turns_started_total | counter | Agent turns started |
calandria_turns_finished_total{outcome} | counter | Turns that ended, by outcome: ok, failed, stopped (a human pressed Stop), interrupted (the agent session never opened, so the turn produced nothing) |
calandria_turns_active | gauge | Turns running right now, read from the in-process registry, not the tasks table; that registry is the only source that’s correct right after a crash |
calandria_db_size_bytes{file} | gauge | calandria.db and its wal / shm sidecars, separately |
calandria_worktrees_size_bytes | gauge | Everything under CALANDRIA_WORKTREES_DIR |
calandria_schedule_runs{status} | gauge | Rows in the schedule run ledger by status (succeeded, failed, missed, skipped_overlap, claimed, running, stopped, interrupted) |
Two sharp edges before you alert on these:
The turn counters are per-process. They live in memory and reset on
restart, so a raw calandria_turns_started_total panel sawtooths on every
deploy. Graph rates, not totals, and keep
calandria_process_start_time_seconds on the same board.
calandria_schedule_runs is a gauge, not a counter. The ledger is
capped per schedule, so these numbers fall as old runs age out. It answers
“is anything stuck or failing right now”, not “how many runs have ever
failed.” Read as a counter, a prune looks like a negative rate.
Every label a metric can take is emitted on every scrape, including zeros,
so an alert on {outcome="failed"} has data before the first failure. The
one series that can be absent is calandria_worktrees_size_bytes, until
its first successful measurement; reading 0 there instead would resolve a
firing alert without a byte reclaimed.
Scraping it
# prometheus.yml: a collector running on the same host, local mode.# Nothing to authenticate: loopback is already an allowed origin.scrape_configs: - job_name: calandria metrics_path: /api/instance/metrics static_configs: - targets: ["127.0.0.1:3000"]Scraping a box behind Access means getting x-service-token onto the
request, which Prometheus 2.49+ can do directly:
scrape_configs: - job_name: calandria-fleet scheme: https metrics_path: /api/instance/metrics http_headers: x-service-token: files: [/etc/prometheus/calandria-fleet-token] static_configs: - targets: ["calandria.example.com"]On an older Prometheus, put a proxy in front that injects the header; the token is a plain shared secret.
A first Grafana row, and the two alerts worth having on day one:
# panel: turn throughput and failure ratesum(rate(calandria_turns_started_total[5m]))sum by (outcome) (rate(calandria_turns_finished_total[5m]))
# panel: disk footprintcalandria_db_size_bytes{file="db"} + calandria_worktrees_size_bytes
# alert: turns are failing, not just runningsum(rate(calandria_turns_finished_total{outcome="failed"}[15m])) / sum(rate(calandria_turns_finished_total[15m])) > 0.5
# alert: a scheduled run is wedged (claimed/running is a transient state)sum(calandria_schedule_runs{status=~"claimed|running"}) > 0 # for: 1h
# alert: the WAL isn't being checkpointedcalandria_db_size_bytes{file="wal"} > 512e6 # for: 30mEverything on the endpoint is cheap to compute except the worktrees
measurement, which runs du over every task checkout on the box. It’s
cached for CALANDRIA_METRICS_SIZE_TTL_MS (default 60000, one minute) so a
15s scrape interval doesn’t walk every node_modules on the instance four
times a minute; raise it if you carry many large worktrees. A scrape only
waits on that walk once, right after a restart.
Notes & caveats
- Permissions: tasks default to Claude Code’s auto mode, where a model
classifier approves calls it judges safe and escalates the rest. Switch a
task, or the app default in Settings → Run defaults, to bypassPermissions
for work that must never block on a prompt, or down to acceptEdits,
default, or plan (a Codex task maps the same five modes onto its sandbox
and approval policy; docs/AGENTS.md has the table). Anything the agent isn’t pre-approved for parks on a
permission card in the transcript, with Allow once / Always allow /
Decline. Read-only tools pass silently; “Always allow” remembers a
command for that project and is revocable in Settings → Run defaults →
Remembered approvals, which also takes a rule typed in ahead of time
through the same Bash-only, prefix-checked policy. A prompt nobody
answers denies itself (
CALANDRIA_PERMISSION_UNATTENDED_MSwhen no tab is open,CALANDRIA_PERMISSION_PROMPT_TIMEOUT_MSwhen one is; both default values are in the Configuration table above), so an auto-started task can’t wedge a turn overnight. Calandria is a control layer, not a sandbox; the isolated worktree is the real security boundary. - One process per database: Calandria runs single-process. Boot clears
what a crash left behind: running flags, queued follow-ups, unanswered
permission cards, in-flight schedule runs. A second process on the same
calandria.dbwould run that recovery pass against a live instance, so the app takes a lock at boot and refuses to start if another process holds it, naming the holder’s pid and host. Crash recovery only runs in the process that owns the database. The lock is a kernel file lock on a separatecalandria.lock.db, named after the database it guards (a pre-renameorchestrator.dbis guarded byorchestrator.lock.db), so a killed instance releases it immediately and a read-onlysqlite3 calandria.dbinspection is unaffected. Two instances need twoCALANDRIA_DB_DIRs;CALANDRIA_DB_LOCK=offdisables the check and is unsupported. The lock only coordinates processes sharing a kernel, so two containers mounting one volume may not see each other’s locks; that configuration is already unsafe, since SQLite’s WAL mode needs shared memory between its users. Use one instance per volume. - Parallel quota: every concurrent task spends your rate limit. N tasks use roughly N times the token rate against one subscription.
- Terminal: the
node-ptysidecar stays bound to127.0.0.1only. The browser reaches it through the app origin at/pty, so remote access goes through your one tunneled hostname.postinstallrestores the exec bit npm can strip off node-pty’s prebuilt helper. - Keep
ANTHROPIC_API_KEYunset unless you chose the wizard’s API-key path. If set, it takes precedence and bills per-use instead of using your subscription. - A stored API key is locked to the account that runs the app. The
wizard’s “I have an API key instead” path writes the key to
anthropic-api-key/openai-api-keybeside the database, never to the settings table. On Linux and macOS that file is mode0600. On Windows, where a POSIX mode is a no-op, it’s restricted with an ACL instead (icacls <file> /inheritance:r /grant:r <you>:(R,W)), leaving only your account with access. If that ACL call fails (noicacls, an unresolvable account, a filesystem with no ACLs such as FAT32 or a mapped network drive), the key is deleted and the save returns an error instead of leaving a credential at permissions nobody checked. In that case, start the app withANTHROPIC_API_KEY/OPENAI_API_KEYset andCALANDRIA_ALLOW_API_KEY_ENV=1instead, so nothing is written to disk. The VAPID private key (vapid.json) is written the same way but fails open: a failed ACL logs a warning and keeps the key instead of disabling push notifications. SetVAPID_PRIVATE_KEYto keep it off disk. On every platform, a local administrator or root can take ownership regardless; this protects against other users, not the machine’s owner. - Retention: the database isn’t append-only forever. A sweep rides the
schedule ticker and ages out the record of finished tasks (terminal,
idle, not snoozed, no queued follow-up, no in-flight scheduled run). A
live task is never touched, however old. The first sweep runs on the tick
after boot. See
CALANDRIA_RETENTION,CALANDRIA_RETENTION_DAYS,CALANDRIA_USAGE_RETENTION_DAYS,CALANDRIA_RETENTION_SWEEP_MS, andCALANDRIA_RETENTION_VACUUMin the Configuration table above. A sweep leaves an aged-out task with an empty transcript (its/clearsummaries survive, so it stays resumable) and an all-time cost of $0.00. Anything deleted is named in one server log line. - Reclaiming the disk: a sweep that deletes anything follows with
PRAGMA wal_checkpoint(TRUNCATE), since in WAL mode the deletes themselves land incalandria.db-waland grow it, so without a checkpoint a big prune raises the on-disk footprint before it falls. A checkpoint can’t shrinkcalandria.dbitself: freed pages go on the freelist for reuse; they are not returned to the filesystem, so the file plateaus instead of shrinking. OnlyVACUUMshrinks it (a write-locked rewrite of the whole database, seconds on a small database, a visible stall on a large one), so it’s opt-in: setCALANDRIA_RETENTION_VACUUM=1to run one after any sweep that deletes rows, or runVACUUM;yourself against a stopped instance. - Worktrees are the bigger disk story, counted in gigabytes, and have
their own switch. Every task runs in its own git
worktree (a full checkout of the project repo) under
CALANDRIA_WORKTREES_DIR. The sweep (CALANDRIA_WORKTREE_RETENTION=on, off by default) rides the same ticker and reclaims checkouts of finished, cold tasks on a shorter window than the table prune above:CALANDRIA_WORKTREE_RETENTION_DAYS, default 14 (0keeps them forever). It skips, and names in the log, any worktree with uncommitted edits or commits the base branch hasn’t absorbed, however old, and it never deletes a branch, so a reclaimed task re-cuts its checkout on the next turn and keeps its diff. It’s opt-in because the retention windows above (180/400 days) are longer than most instances have existed, and a window in weeks would start removing checkouts on the first tick after an upgrade nobody asked for. The manual path (Settings → Storage, which can also discard unmerged work after you acknowledge it) works either way. Landing is the other trigger, and it isn’t on this clock at all. When a task’s PR reports merged, or its branch is merged locally, the session header’s Reclaim button catches the local base branch up with origin, removes the checkout, deletes the local branch, and marks the task done. Unlike the sweep it does delete a branch, since the diff it carried is in the base branch by then, and like the sweep it never discards uncommitted edits or commits the remote never received without an explicit acknowledgement nobody can give unattended. The project’sauto_reclaimsetting (off by default) does the same without being asked as soon as no turn is executing. If a pull request lands during a turn, the reclaim retries afterturn_end. Dirty worktrees and commits never pushed to the merged pull request remain for manual acknowledgement. See Features. The disk warning runs whether or not the sweep does: seeCALANDRIA_WORKTREES_DISK_WARN_GBin the Configuration table above. When it fires, a line goes to the server log each pass while the directory stays over the threshold, the reading is served onGET /api/instance/schedulerunderworktrees, and Settings → Storage shows it above the reclaim list. The same directory is also thecalandria_worktrees_size_bytesmetrics gauge; see Metrics. The total includes in-flight checkouts, which nothing here touches, so on a busy instance the honest reading might be “40 GB of worktrees, 6 GB reclaimable”; the rest is answered by finishing or deleting tasks, not by a sweep. - Delete is hard delete: a removed project’s chat history is gone (your code on disk is untouched).