pardy.at vivarium

An ATProto atmosphere in a box: PLC, PDS, relay, Jetstream, Constellation, Slingshot and more on one port.

0 1 pulls 2 Updated 23h
linux/amd64 linux/arm64 Multi-arch
docker pull atcr.io/pardy.at/vivarium:latest
Image Size Layers
131.2 MB 9
Pushed 23h
Vulnerabilities
Loading...
Pulls
1 total
Last pull 23h

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, removeWriter, clearRecords, account migration via importRepo + exportSpaceMeta/importSpaceMeta) and the three baseline space policies: personalspace, memberspace (publicRead/publicWrite, allowedApps checked against client attestations, com.atproto.memberspace.* member list with per-member read/write) and managedspace (checkUserAccess on the managing app), with optional published policy/member records. 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/pardy.at/vivarium
docker run --rm -p 2580:2580 -v vivarium:/data atcr.io/pardy.at/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/pardy.at/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 29, 2026 spaces alpha
    (0.0.0-spaces-alpha-20261029171913); 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.space.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