Quick start
This guide gets you from a fresh clone to a running /api/v1/* service on the same runtime the hosted deployment uses.
Prerequisites
Section titled “Prerequisites”- Node.js 22+ (see
.nvmrc) - A Postgres database reachable over HTTPS. The SQL client is
@neondatabase/serverlessin HTTP mode, which POSTs tohttps://<host>/sqlrather than opening a TCP connection — so a plainlocalhostPostgres will not answer it. A free Neon branch is the path of least resistance, and using a throwaway branch per developer is the intended shape. - A domain you control, serving a
did:webdocument over HTTPS — see step 3. This is the one prerequisite you can’t fake locally.
1. Clone and install
Section titled “1. Clone and install”git clone https://github.com/bbthorson/antiphony.gitcd antiphonynpm install2. Create the database
Section titled “2. Create the database”Apply the schema to an empty database. It is one begin/commit, so a failed apply rolls back whole:
psql "$DATABASE_URL" -f apps/core-api/db/schema.sqlFour tables come out of it: posts, audio_transcripts, idempotency_keys, and rate_limits. Two properties are worth knowing because they are enforced rather than conventional — the query facets (author_id, kind, cid, …) are generated columns off the record JSON, so they cannot drift from it, and the “a reply has a parent and a root author; a prompt has neither” invariant is a check constraint, not just a Zod refinement.
3. Pin your tenant’s app DID
Section titled “3. Pin your tenant’s app DID”Antiphony is the repo owner for the records it writes, so every post’s at:// URI is authored under a tenant app DID (at://did:web:<your-domain>/dev.antiphony.audio.post/<rkey> — see the lexicons). That DID is pinned per tenant via ANTIPHONY_APP_DIDS, and the core proves custody of it before any handler runs.
Serve this at https://<your-domain>/.well-known/did.json:
{ "id": "did:web:<your-domain>", "service": [ { "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://<your-antiphony-host>" } ]}Setting ANTIPHONY_PDS_HOST (step 4) tightens this further — with it, the core also requires the DID document’s serviceEndpoint to point back at your deployment, not merely to exist.
4. Configure and start core-api
Section titled “4. Configure and start core-api”Worker secrets are not environment variables, so local config goes in apps/core-api/.dev.vars (gitignored) rather than your shell. Create it:
DATABASE_URL="postgresql://…@ep-….neon.tech/neondb?sslmode=require"ANTIPHONY_APP_TOKENS="local:a-local-dev-token-at-least-32-chars-long"ANTIPHONY_APP_DIDS="local:did:web:your-domain.example"ANTIPHONY_PUBLIC_BASE_URL="http://localhost:8787"Then:
npm run devThat runs wrangler dev, which serves on http://localhost:8787 and simulates the R2 bucket, the KV namespace, the queue, and the rate-limit Durable Object locally. Smoke test:
curl http://localhost:8787/health# → {"ok":true,"sha":"dev","deployedAt":null,# "backend":"postgres","records":"empty","blobs":"empty"}Read backend and the two presence fields rather than ok alone. ok:true is true of any process that started; backend says which store you are actually talking to, and records/blobs say whether it has anything in it — which is the question that went unanswered for twenty minutes during the production cutover while /health reported a cheerful ok:true over an empty database and an empty bucket.
ANTIPHONY_PUBLIC_BASE_URL is required, not cosmetic: the audio proxy streams bytes rather than redirecting, so AudioEmbedView.url is an absolute URL pointing back at this service. Without it, a post with audio hydrates with no embed at all.
Those two local: entries describe one tenant: ANTIPHONY_APP_TOKENS is the credential it authenticates with, ANTIPHONY_APP_DIDS is the at:// authority it writes under. A tenant needs both — core-api warns about a tenant configured in only one. Tenancy comes from the credential; there’s no deployment-level default.
5. Hit a real endpoint
Section titled “5. Hit a real endpoint”Every data route requires your service token. There’s no end-user sign-in to do: your app authenticates as itself and asserts which of its users is acting (see Authentication).
TOKEN=a-local-dev-token-at-least-32-chars-long
# 1. Upload audio (returns a content-addressed blob ref to embed).# Max 25 MB, and the MIME type must be one core-api accepts.curl -X POST http://localhost:8787/api/v1/audio/upload \ -H "Authorization: Bearer $TOKEN" \ -H "X-Antiphony-Acting-Actor: local-user-1" \ -F "file=@your-clip.wav;type=audio/wav"# → {"success":true,"data":{"blob":{"$type":"blob","ref":{"$link":"bafkrei…"},# "mimeType":"audio/wav","size":8044}}}
# 2. Create the post, with that blob ref as the embed's `audio`curl -X POST http://localhost:8787/api/v1/posts \ -H "Authorization: Bearer $TOKEN" \ -H "X-Antiphony-Acting-Actor: local-user-1" \ -H "Content-Type: application/json" \ -d '{ "text": "What should we cover next?", "embed": { "$type": "dev.antiphony.embed.audio", "audio": { …blob from above… } } }'# → {"success":true,"data":{"postId":"3mrj6z52xwsj7"}}
# 3. Read it back, hydratedcurl "http://localhost:8787/api/v1/posts/3mrj6z52xwsj7" \ -H "Authorization: Bearer $TOKEN" \ -H "X-Antiphony-Acting-Actor: local-user-1"The read returns the post under your pinned authority, with the acting actor stamped as the opaque authorId:
{ "uri": "at://did:web:your-domain.example/dev.antiphony.audio.post/3mrj6z52xwsj7", "cid": "bafyreifiw3szxfexpsmwy5fzeyqhu73zunk74pmeoqjjicysniki4sehgi", "kind": "prompt", "authorId": "local-user-1", "record": { "text": "What should we cover next?", "createdAt": "…" }, "viewer": { "isAuthor": true, "canReply": true }}Note the acting-actor header on the read as well: it’s what populates viewer. Drop it and you get the same post with an anonymous, viewer-less projection.
The fastest way to see this loop in a browser is the reference app, which ships a small BFF that holds the token for you.
Next steps
Section titled “Next steps”- Configure for production deploy — see Configuration.
- Understand the records you’re creating — see The Antiphony lexicons.
- Browse the full endpoint surface — see API reference.