Skip to content

Quick start

This guide gets you from a fresh clone to a running /api/v1/* service on the same runtime the hosted deployment uses.

  • Node.js 22+ (see .nvmrc)
  • A Postgres database reachable over HTTPS. The SQL client is @neondatabase/serverless in HTTP mode, which POSTs to https://<host>/sql rather than opening a TCP connection — so a plain localhost Postgres 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:web document over HTTPS — see step 3. This is the one prerequisite you can’t fake locally.
Terminal window
git clone https://github.com/bbthorson/antiphony.git
cd antiphony
npm install

Apply the schema to an empty database. It is one begin/commit, so a failed apply rolls back whole:

Terminal window
psql "$DATABASE_URL" -f apps/core-api/db/schema.sql

Four 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.

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.

Worker secrets are not environment variables, so local config goes in apps/core-api/.dev.vars (gitignored) rather than your shell. Create it:

apps/core-api/.dev.vars
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:

Terminal window
npm run dev

That 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:

Terminal window
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.

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).

Terminal window
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, hydrated
curl "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.