An Atmosphere in a box
README.md

vivarium #

vi·var·i·um /vaɪˈvɛəriəm/ noun (plural vivaria or vivariums)

An enclosure, container, or structure adapted or prepared for keeping living organisms under semi-natural conditions for observation or study. A terrarium or aquarium is a vivarium.

Origin: Latin vīvārium "a place for keeping live animals", from vīvus "alive, living".

Status: pre-release. Recently rewritten as a monorepo with a plugin kernel and a spec-built PDS (no more @atproto/pds shims).

An Atmosphere in a Box #

In ATProto, the atmosphere is the living network your software swims in: the PLC directory, PDSes, relays, Jetstream, backlink indexes, handle resolution — everything an appview, a feed generator, a labeler, or an agent actually talks to.

Vivarium is that atmosphere in a box. It recreates the entire surface as one binary on one port — a semi-natural enclosure where your app behaves exactly as it would in the wild, but under controlled conditions you can observe, seed, and reset at will. No docker-compose of six services, no dependence on the public network, no waiting for indexes to catch up.

npx @vivarium-dev/cli
# vivarium is up: http://localhost:2580

Point your app's PLC URL, PDS URL, Jetstream URL, Constellation URL, and Slingshot URL all at http://localhost:2580. That's the setup.

What's in the box #

service what you get
PLC directory The full plc.directory API (GET /did:plc:…, /log, /log/audit, /data, /export, POST) with real @did-plc/lib validation, rotation-key recovery windows included.
PDS Built to spec on the @atproto/* libraries (@atproto/repo MSTs on bun:sqlite): com.atproto.server.* sessions + app passwords, repo.* CRUD/applyWrites/uploadBlob/importRepo, sync.* incl. subscribeRepos, getRepo?since, blobs; identity + PLC operation methods; atproto-proxy pipethrough to your own appview with service JWTs; and a full OAuth authorization server (PAR, PKCE, DPoP, client metadata docs + loopback clients) with a passwordless pick-an-account → consent UI. Real API fidelity, minus the parts you don't want in a sandbox (see What it isn't).
Permissioned data Proposal 0016 as it stands on the permissioned-data branch: com.atproto.space.* (writes, reads, listRepoOps incremental sync, getRepo two-root CARs, LtHash commits with deniable signatures, delegation tokens → space credentials bound to a P-256 key by HTTP message signatures (10-minute default, early revocation via notifyCredentialRevoked), writer set sequenced by space revision with listRepos catch-up, write notifications with retry) and the required com.atproto.simplespace.* management (independent read/write policies — member-list / public / managing-app — putMember with per-member read/write, app allow-lists with client attestations). space: OAuth scopes are enforced.
Relay surface com.atproto.sync.* on the same origin, incl. listRepos, listReposByCollection and subscribeRepos. With one built-in PDS the relay's sequence is the PDS's; requestCrawl/notifyOfUpdate are no-ops.
Jetstream v2 at /xrpc/network.bsky.jetstream.subscribeEvents (xrpc.v1.json subprotocol, collections/dids/kinds, inclusive seq cursors — a unix-µs cursor is translated — envelope + flat #commit/#identity/#account/#sync payloads, full replay from seq 1). The frozen v1 wire is still served at /subscribe (wantedCollections incl. prefix.*, wantedDids, time_us cursors, options_update), exactly like the public hosts. Replay/snapshot HTTP methods answer 501.
Constellation (microcosm) A backlink index over the local firehose serving blue.microcosm.links.*: getBacklinks, getBacklinksCount, getBacklinkDids, getManyToMany, getManyToManyCounts.
Slingshot (microcosm) blue.microcosm.identity.resolveMiniDoc / repo.getRecordByUri (and the original com.bad-example.* names), plus com.atproto.repo.getRecord / sync.getRecord that follow records to their owning PDS.
Comail (docs.comail.at) The relay's HTTP surface as a mock: POST /v1/send (full validation + error vocabulary, categories, suppression list), GET /v1/events (cursor paging), /member/status, /member/deliverability, /member/forward-to, HMAC-signed webhooks with retries, and SMTP submission on a dedicated port (--smtp-port, default 2587) for clients that send with attachments. Enroll DIDs/domains and get API keys through localhost.vivarium.comail.enroll; inspect the outbox with listMessages; force bounces/complaints with setOutcome. email.atmos.attestation records are validated by the PDS. Not served: labels, inboxes, aliases (submission is on 2587, not 587).
Atmosphere Money (atmosphere.money) The AT Protocol payment broker as a mock, at SDK fidelity — point @atmosphere-money/app-node's brokerUrl/appViewUrl at the box and it works unchanged: strict network.attested.payment.* initiation/status, money.atmosphere.* (payout status, app config, recipient approvals, subscriptions, refunds, catalog, event log), tickets.atmosphere.* (events, types, capacity, holds, free claims, issuance, verify, check-in), app service-auth (the production broker audience is accepted), hosted checkout at /checkout/<token> (Pay/Fail/Cancel), signed webhooks + XRPC receiver events, and the developer utility API (/api/app/developer/*, /api/webhooks/atm) so @atmosphere-money/mcp works. Completing a payment writes real network.attested.* records (payer record, broker + creator proofs) with attestation state the SDK reads at tier strict. Drive it with localhost.vivarium.atmosphere.*: registerApp, setDefaults, completeCheckout, renewSubscription, refunds/disputes, deliveries + redrive. Memberships, pledges, analytics, waitlists → 501.
Handle resolution com.atproto.identity.resolveHandle, the appview's app.bsky.actor.searchActorsTypeahead (drop-in for public.api.bsky.app), and an authoritative DNS server for the handle domain (_atproto.<handle> TXT + in-zone A records) for split-DNS setups.
Management API localhost.vivarium.* XRPC — create passwordless accounts, seed records in bulk, reset the world — and a stdio MCP server exposing the same to agents.

Everything is wired together internally: an ingester consumes the PDS's own firehose and fans out to the Jetstream log, the backlink index, and the actor directory, so a write shows up in Constellation within milliseconds — not the multi-second lag apps have to paper over against the public index.

Sealed, or porous — your call #

By default the box has a window to the real world: unknown DIDs proxy through to plc.directory, unknown repos are fetched from their owning PDS, unknown handles resolve via DNS/.well-known, and typeahead merges public.api.bsky.app results after local ones. This lets local test identities and real network identities mix in one app.

Pass --no-upstream and the box is fully sealed: nothing leaves the process.

Your own services are the exception either way. Whatever runs on loopback, plus any hostname you name with --app-host (a compose service, host.docker.internal), is treated as yours: its did:web resolves over plain http, atproto-proxy forwards to it, it can host an http:// OAuth client metadata document, and it may receive comail/Atmosphere webhooks — sealed or not. Nothing else can be a webhook target.

Install #

npx @vivarium-dev/cli              # run without installing; no Bun needed
npm install -g @vivarium-dev/cli   # or install the `vivarium` command

@vivarium-dev/cli is a prebuilt binary: npm installs only the one for your machine (macOS arm64/x64, Linux arm64/x64 on glibc or musl, Windows x64). Vivarium itself is written for Bun, so with Bun you can also run it from source:

bunx @vivarium-dev/core        # run without installing
bun add -g @vivarium-dev/core  # or install the `vivarium` command

Or from source (tangled.org/chris.pardy.family/vivarium):

git clone https://tangled.org/chris.pardy.family/vivarium && cd vivarium
bun install
bun run dev                        # run from source
bun run build && ./packages/core/dist/vivarium   # compile to a single standalone binary

Docker #

A multi-arch image (linux/amd64, linux/arm64, so Apple Silicon runs it natively) is published to ATCR:

docker run --rm -p 2580:2580 -p 5300:5300/udp -p 2587:2587 atcr.io/chris.pardy.family/vivarium
docker run --rm -p 2580:2580 -v vivarium:/data atcr.io/chris.pardy.family/vivarium --data /data

Arguments after the image name are passed to vivarium serve (the image already listens on 0.0.0.0). The box advertises http://localhost:2580 in DID documents and service URLs, which is right for apps on the host. When your app runs in another container, tell the box the address it's reached at:

services:
  vivarium:
    image: atcr.io/chris.pardy.family/vivarium
    command: ["--public-url", "http://vivarium:2580", "--app-host", "app"]
  app:
    environment:
      ATPROTO_SERVICE: http://vivarium:2580

--app-host app is the other direction: it lets the box call your app back (its did:web:app%3A3000, atproto-proxy, OAuth client metadata, webhooks) the way it would call something on localhost.

bun run docker:build builds it locally, bun run docker:test builds and smoke-tests it (PLATFORM=linux/amd64 to test the other arch), and bun run docker:publish pushes both arches (see scripts/docker.sh for the knobs; ATCR_APP_PASSWORD logs in with an atproto app password).

Run #

vivarium [serve] [options]
  -p, --port <n>          public HTTP port for every service (default 2580)
      --host <addr>       interface to listen on, HTTP + DNS (default 127.0.0.1;
                          0.0.0.0 exposes an unauthenticated box to your LAN)
      --dns-port <n>      DNS port, UDP (default 5300)
      --smtp-port <n>     comail SMTP submission port, implicit TLS (default 2587)
      --dns-answer-ip <a> address for in-zone A answers (default 127.0.0.1)
      --public-url <url>  origin clients reach the box at: advertised in DID docs,
                          service DIDs, OAuth metadata, getInfo (default
                          http://localhost:<port>; set it behind a proxy or in compose)
      --app-host <host>   hostname of your own service when it isn't on loopback
                          (repeatable): treated like localhost, see Sealed, or porous
  -d, --data <dir>        persist state (default: ephemeral temp dir)
      --domain <d>        handle domain (default vivarium.test)
      --no-upstream       fully isolated; no proxying to the real network

vivarium mcp [--url <vivarium url>]

By default an instance is ephemeral — every start is a fresh atmosphere. Pass --data ./some-dir to keep accounts, records, and cursors across restarts. Accounts record the public URL (port included) in their DID documents when they're created, so keep it stable for a data dir; vivarium warns if it changes.

Once it's up, ask it to describe itself rather than hardcoding URLs:

curl localhost:2580/xrpc/localhost.vivarium.getInfo
# { "url": "http://localhost:2580", "domain": "vivarium.test",
#   "services": { "plc": …, "pds": …, "jetstream": …, "constellation": …, … } }

From a test suite #

@vivarium-dev/client drives a box from Node or Bun. Install it next to @vivarium-dev/cli (no Bun needed) or @vivarium-dev/core (runs on Bun):

npm install -D @vivarium-dev/client @vivarium-dev/cli
import { startVivarium } from '@vivarium-dev/client'

const viv = await startVivarium({ upstream: false })      // spawns, waits until ready
const alice = await viv.createAccount('alice.vivarium.test')
await viv.seedRecords([{ repo: alice.did, collection: 'app.bsky.feed.post', record: {/*…*/} }])
await viv.awaitCaughtUp()                                 // every index has the write
await viv.xrpc('blue.microcosm.links.getBacklinksCount', { params: {/*…*/} })
await viv.reset()                                         // fresh world, same process
await viv.stop()

With vitest, add the global setup and use the fixture (see examples/vitest-app):

// vitest.config.ts
export default defineConfig({ test: { globalSetup: ['@vivarium-dev/client/vitest/global-setup'] } })

// likes.test.ts
import { test, expect } from '@vivarium-dev/client/vitest'
test('a like is indexed', async ({ viv }) => {
  const alice = await viv.createAccount(viv.handle('alice'))   // alice-<scope>.vivarium.test
  /* … */                                                     // viv's accounts are deleted after the test
})
test('needs a pristine world', async ({ vivFresh }) => { /* its own instance, ~0.5 s */ })

Fixtures, by isolation: viv is a scope on the shared instance — the accounts it creates are cleaned up after the test and nothing else is touched, so it is safe under vitest's default parallel files (use viv.handle(name) for collision-free handles). vivFresh spawns a new instance per test. vivReset fully resets the shared instance first (only with fileParallelism: false); vivShared touches nothing. Outside vitest, viv.scope() gives the same scoped client.

VIVARIUM_URL attaches to a running instance instead of spawning one; VIVARIUM_BIN picks the executable; viv.sessionFor(handle) returns data AtpAgent#resumeSession accepts, so the real SDK works against the box.

Populate it #

The management API (localhost.vivarium.*, defined in lexicons/localhost/vivarium/) is unauthenticated XRPC shaped for scripts and tools:

method what
getInfo service map + config
createAccount {handle} → did + access/refresh JWTs, no password
createSession {identifier} → fresh JWTs, no credentials
deleteAccount {identifier}
listAccounts dids, handles, display names
seedRecords batch record writes as any local accounts, no sessions needed
reset wipe to a fresh instance without restarting
# an account
curl -X POST localhost:2580/xrpc/localhost.vivarium.createAccount \
  -H 'content-type: application/json' \
  -d '{"handle":"alice.vivarium.test"}'

# some records
curl -X POST localhost:2580/xrpc/localhost.vivarium.seedRecords \
  -H 'content-type: application/json' \
  -d '{"writes":[{"repo":"alice.vivarium.test","collection":"app.bsky.feed.post",
       "record":{"$type":"app.bsky.feed.post","text":"hello, box","createdAt":"2026-01-01T00:00:00Z"}}]}'

Everything else is just the normal ATProto surface — createSession, createRecord, subscribeRepos, /subscribe, getBacklinks, … — exactly as your app would call it in production.

MCP #

vivarium mcp runs a stdio MCP server that maps the management API 1:1 to tools (create_account, seed_records, reset, …), so an agent can build its own test world:

{ "mcpServers": { "vivarium": { "command": "vivarium", "args": ["mcp"] } } }

It talks to http://localhost:2580 by default; pass --url for another instance.

What it isn't #

Vivarium is a test environment, not a production deployment.

  • Not a real PDS. No invites, no email, and any password is accepted — accounts are effectively passwordless (an app password, if you make one, still yields the right scoped session). The OAuth login page lists local accounts and lets you sign in as a brand-new handle, which creates it. Never expose it publicly.
  • OAuth is spec-shaped, dev-friendly. http:// client metadata documents on loopback hosts and --app-hosts are accepted (production servers require https), and DPoP nonces rotate per minute. Everything else — PAR-only, PKCE S256, DPoP-bound tokens, transition:* and granular scopes — matches the real thing.
  • Permissioned data is built to a proposal. It tracks the permissioned-data branch's lexicons as of the October 22, 2026 spaces alpha (0.0.0-spaces-alpha-20261022141228); expect churn. Repo-host notification and space-deletion retries are held in memory (an ephemeral box), not in a durable outbox. Two dev-box shortcuts: a space: scope with no collection covers every collection (a sealed box can't resolve the space type declaration the spec defaults to), and spaces must be created explicitly (com.atproto.simplespace.createSpace) — nothing springs into existence on first write.
  • Comail is a relay mock, not a mail server. Nothing leaves the box; every accepted message is recorded and "delivered" (or bounced/complained if you say so). Deviations from smtp.atmos.email: SMTP submission uses implicit TLS (connect with nodemailer secure: true) on port 2587 with a committed self-signed dev cert (tls: { rejectUnauthorized: false }), not STARTTLS on 587 — Bun can't terminate server-side STARTTLS; webhooks may target http:// loopback or an --app-host and retry on a seconds-scale schedule (200 ms, 1 s, 5 s, then dead) instead of 30 s…24 h; enrollment/API keys come from the management API rather than DNS verification.
  • Atmosphere Money is a broker mock, no money moves. Permissive by default: every local DID is payable, recipient approvals auto-approve, and apps self-register on first authed call (localhost.vivarium.atmosphere.setDefaults opts into production-strict behavior). Checkouts settle via the checkout page, completeCheckout, or auto-complete mode — never a card. The broker is a local account (atm.<domain>), not the production broker DID, though the SDK's hardcoded audience is accepted; XRPC-receiver verifiers must pass expectedIssuerDid. Webhooks retry on the same fast schedule as comail; renewals and hold expiry are management-driven, never wall-clock; proof records are written directly rather than through the delegated-write dance.
  • app.bsky.* isn't served here. Requests carrying atproto-proxy are forwarded to that DID's service (your appview, even in a sealed box when it runs on loopback or an --app-host); without the header they 501.
  • Not a real relay. There's one PDS in the box; the relay surface is that PDS's stream re-served on the shared origin.
  • Jetstream compress=true (zstd) is not supported on the v1 wire; connect uncompressed. Network Replay (planSnapshot/listSegments/getSegment/getBlock) is 501 — the live tail replays from seq 1.
  • Not the whole appview. It provides what an appview consumes (plus typeahead and resolveHandle as conveniences). Your appview is the thing you bring.

How it hangs together #

Vivarium is a kernel + plugins, all on one port. The kernel owns config, sqlite, the PLC directory, the PDS, the firehose ingester, the actor/identity index, DNS, and the localhost.vivarium.* management API. Everything else — Jetstream, Constellation, Slingshot, resolver/typeahead — is a plugin (packages/core/src/plugins/) that gets read-only PDS access, firehose push, identity resolution, a gated upstream fetch, its own state, and a set of XRPC methods. Plugins run before the PDS and may shadow its methods (e.g. getRecord) with a superset handler; returning null falls through. The ingester consumes the PDS firehose (cursor-persisted, from seq 0) and feeds the actor index first, then every plugin, so a write is visible everywhere within milliseconds.

Packages: @vivarium-dev/core (kernel + built-in plugins + CLI + MCP), @vivarium-dev/pds (the spec-built PDS), @vivarium-dev/lexicons (the localhost.vivarium.* lexicons), @vivarium-dev/client (test-runner library + vitest fixture), and @vivarium-dev/cli (a Node launcher for core compiled to a binary, one @vivarium-dev/cli-<platform> package per target). See AGENTS.md for the architecture rules.

Tests #

bun run test (turbo) — integration tests boot real instances on random ports and exercise every service end to end, hermetically. Two tests that proxy live to plc.directory and real PDSes are opt-in: VIVARIUM_NETWORK_TESTS=1 bun run test.

License #

MIT