Skip to content
Protocol / dev.antiphony.*

The Antiphony lexicons

The AT Protocol record definitions under dev.antiphony.* describe everything Antiphony stores. The REST API, the Zod schemas in packages/shared, and every app built on the core derive from them. If you read one page before building, read this one.

Source of truth: lexicons/dev/antiphony/. This page is the guided tour.

Why do these look like Bluesky’s records?

Section titled “Why do these look like Bluesky’s records?”

Antiphony mirrors the app.bsky.* shapes wherever one exists, so an AT Protocol client already understands the record. It adds a new shape only where audio call-and-response needs one. There are two such gaps:

Audio embeds. atproto has image, video, external and record embeds, but no audio. dev.antiphony.embed.audio is the protocol’s audio attachment.

Timed transcripts. A machine transcript stored as platform enrichment, not a field the author writes.

AntiphonyMirrors
audio.postapp.bsky.feed.post
embed.audioapp.bsky.embed.video
embed.recordWithAudioapp.bsky.embed.recordWithMedia
actor.profileapp.bsky.actor.profile

dev.antiphony.audio.post

One record carries both halves of the conversation. A post without a reply is a prompt, the root of a thread. A post with one is a reply. The audio rides in embed; text is what the author typed, never the transcript.

Hover a field to find it in the example.
textstring
What the author typed: a question or caption. Can be empty for audio-only posts. Required.
titlestring?
Optional headline for prompts. It does not decide whether a post is a prompt.
embedunion?
The audio. Usually embed.audio; also recordWithAudio, a bsky record or an external link.
replyref?
Present only on replies: { root, parent }, each a strongRef.
langsstring[]?
Up to three BCP-47 language tags.
labelsunion?
Content warnings the author applies to their own post.
createdAtdatetime
ISO 8601. Required.

Threading is content-addressed. reply.root points at the prompt; reply.parent points at the post being answered. Who may reply is not a field: the AppView gates replies into participant-only sub-threads. See reply gating.

Example
{  "$type": "dev.antiphony.audio.post",  "text": "First-job advice?",  "title": "Your first year at work",  "embed": {    "$type": "dev.antiphony.embed.audio",    "audio": {      "$type": "blob",      "ref": { "$link": "bafkreihdwdcefgh4dqkjv67uzc…" },      "mimeType": "audio/mp4",      "size": 672310    },    "durationMs": 42000  },  "langs": [ "en" ],  "createdAt": "2026-09-24T18:00:00.000Z"}

dev.antiphony.embed.audio

The embed has two forms. The record is what you write: the stored bytes and facts that don’t depend on rendering. The view is what a read gives back: a playable URL, the transcript, and the values that describe the audio the listener actually hears.

Record · #main
audioblob
audio/*, up to 100 MB, addressed by CID. Required. The upload endpoint has a tighter limit.
durationMsinteger?
Duration in milliseconds, the unit used everywhere.
altstring?
A short description the author writes, like image alt text. Not the transcript.
waveforminteger[]?
Peaks normalized 0–100, so players can draw instantly.
View · #view
urluri
A playable URL: the processed variant if one exists, otherwise the original. Short-lived and signed. Required.
durationMsinteger?
The processed duration once trim has run; otherwise the record value.
waveforminteger[]?
Server-computed peaks once the waveform stage is ready; otherwise the client peaks.
altstring?
Copied from the record, never processed.
transcriptref?
Lifted from the transcript record at read time. Absent until transcription finishes.

url, durationMs and waveform move together. Once processing produces a variant, all three describe it, or none do. If you cached duration or peaks at upload, read them again from the view: trim shortens the audio and waveform recomputes the peaks. Uploads have a tighter size limit than the lexicon; see Limits.

Example
{  "$type": "dev.antiphony.embed.audio",  "audio": {    "$type": "blob",    "ref": { "$link": "bafkreihdwdcefgh4dqkjv67uzc…" },    "mimeType": "audio/mp4",    "size": 672310  },  "durationMs": 42000,  "alt": "Alice asks about first jobs",  "waveform": [ 12, 28, 45, 60, 75, 90, 85, 60 ]}

dev.antiphony.audio.transcript

In its own record, never on the post. The transcript is platform enrichment, like denoise or waveforms. It points at the post by StrongRef, the same pattern as likes and labels, and is lifted into embed.audio#view.transcript at read time.

subjectstrongRef
The post whose audio this transcribes. Required.
transcriptref
A #timedTranscript: segments plus an optional text rollup. Required.
langstring?
BCP-47 tag of the transcript.
modelstring?
The model or provider that produced it.
createdAtdatetime
Required.

A #timedTranscript is a list of segments, each { startMs, endMs, text }, plus an optional plain-text rollup for consumers that don’t need timing.

Example
{  "$type": "dev.antiphony.audio.transcript",  "subject": {    "uri": "at://did:web:voxpop.audio/dev.antiphony.audio.post/3k6w",    "cid": "bafyreib2rxk3ry…"  },  "transcript": {    "segments": [      {        "startMs": 0,        "endMs": 2400,        "text": "What’s the best advice"      },      {        "startMs": 2400,        "endMs": 4100,        "text": "you got in your first year?"      }    ]  },  "lang": "en",  "model": "whisper",  "createdAt": "2026-09-24T18:00:41.000Z"}

dev.antiphony.embed.recordWithAudio

A post that quotes another record and carries its own audio. The audio counterpart of app.bsky.embed.recordWithMedia.

dev.antiphony.actor.profile

One record per actor at the self rkey, with a public handle, an optional usage intent and an RSS feed. Lexicon-only: the core never stores or serves it. Profiles belong to the calling app; this shape exists so a federating deployment has somewhere to project them.

A prompt and its replies are all audio.post records, threaded by StrongRef. Each carries an embed.audio. The transcript is its own record, folded into the embed’s view only once it exists.

Records validate against the official @atproto/lexicon parser, blob refs use the canonical JSON shape, and CIDs are real content addresses.

Blob CIDs. CIDv1, raw codec, sha2-256 over the audio bytes. Storage location is derived from the CID and never stored, so records stay portable across deployments.

Record CIDs. CIDv1, dag-cbor, sha2-256 over the public record fields. Every view’s cid, and every StrongRef in a reply, is verifiable.

The at:// authority is the tenant app’s DID. Antiphony owns the repo, so a post’s URI authority is always the tenant’s did:web. The acting user’s identity rides alongside as authorDid, outside the record CID. See Tenant identity and the authority model.

Blob ref
{  "$type": "blob",  "ref": { "$link": "bafkreihdwdcefgh4dqkjv67uzc…" },  "mimeType": "audio/mp4",  "size": 672310}
Post URI
at://did:web:<tenant>/dev.antiphony.audio.post/<rkey>