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/pdsshims).
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-databranch'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: aspace:scope with nocollectioncovers 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 nodemailersecure: 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 targethttp://loopback or an--app-hostand 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.setDefaultsopts 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 passexpectedIssuerDid. 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 carryingatproto-proxyare 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