Extending atjam
How-to. Adapt atjam to your own domain without changing
at.atjam.*. This is the headline use case: atjam stays generic; you bring the specifics.
atjam is a generic coordination layer. It does not know what a "song" is, what a "demo day" is, or where a "track" lives. It leaves four deliberate openings for you to plug your domain into:
| Extension point | On record | Shape | What it carries |
|---|---|---|---|
subject | at.atjam.round | unknown ($type-discriminated) | The structured subject of the round. |
closingEvent | at.atjam.round | unknown ($type-discriminated) | An optional closing event (listening party, demo day). |
acceptedSubmissionTypes | at.atjam.round | string[] of NSIDs | Which deliverable record types this round accepts. |
payload | at.atjam.submission | StrongRef | The deliverable record itself, on its own app. |
The rule that makes this work is design principle #4: one namespace per
owner. at.atjam.* holds only the generic primitives. Anything specific to
your domain — a song, a track, a manuscript — you define under your own
domain's namespace and embed by reference. atjam never enumerates your types.
The EPTSS pattern, end to end
EPTSS ("Everyone Plays the Same Song") is the motivating real adopter. Each month, everyone covers the same song; participants submit a recording. Here is how EPTSS maps onto atjam's extension points.
EPTSS's own implementation lives in a separate repository and is not part of this repo. The pattern below is described from atjam's published facts — the package README's "How rounds reference work on other apps" section, the
subjectfield's own description inround.json, and the observed shape of a live EPTSS round. Treat the EPTSS-side code as conceptual; only theat.atjam.*contract is verified here.
1. Define your subject lexicon under your own domain
EPTSS publishes its song record under its own namespace, site.eptss.song —
not part of at.atjam.*. A live EPTSS round ("Round 29") was observed with
this subject:
{
"$type": "site.eptss.song",
"title": "Right Back To It",
"artist": "Waxahatchee ft. MJ Lenderman",
"createdAt": "…"
}
That { $type, title, artist, createdAt } is the observed shape of the
embedded subject. The $type discriminator is what lets consumers recognize
it; the rest is EPTSS's business. You would publish site.eptss.song as its
own Lexicon under your domain, exactly as atjam publishes at.atjam.* (see
PUBLISHING.md).
2. Embed the subject in the round
When the organizer creates the round, they put the subject record in the
round's subject field. subject is typed unknown in the lexicon, so it
accepts any $type-tagged record:
{
$type: NSIDS.round,
jam: jamRef,
assignment: "Cover this month's song. Submit your recording.",
subject: {
$type: "site.eptss.song",
title: "Right Back To It",
artist: "Waxahatchee ft. MJ Lenderman",
createdAt: new Date().toISOString(),
},
acceptedSubmissionTypes: ["fm.plyr.track"], // step 3
milestones: [{ label: "submission-deadline", date: deadlineIso }],
createdAt: new Date().toISOString(),
}
A consumer that understands site.eptss.song renders the song nicely; one that
does not can still show the round, ignore the unknown subject, and fall back to
the plain assignment text. That is the point of $type-discriminated
unknown: forward and cross-app compatibility.
3. Declare the accepted deliverable types
EPTSS deliverables are tracks on plyr.fm, whose record type is fm.plyr.track.
The round declares this explicitly:
acceptedSubmissionTypes: ["fm.plyr.track"]
acceptedSubmissionTypes is an array of NSIDs (at least one). It is the round
telling participants and consumers which record types count as a valid
submission. atjam itself does not validate the target's reachability — it
validates record shape — but the web app's submit form checks that a pasted
deliverable's collection is in this list before writing the submission (see
web/app/components/submit-form.tsx).
4. Participants submit by strong-reffing their deliverable
A participant records their cover on plyr.fm (creating an fm.plyr.track
record on their own PDS), then writes an at.atjam.submission whose payload
strong-refs that track:
{
$type: NSIDS.submission,
round: { uri: round.uri, cid: round.cid },
payload: { uri: trackUri, cid: trackCid }, // the fm.plyr.track record
createdAt: new Date().toISOString(),
}
atjam stores only the reference. The audio lives on plyr.fm; the track record lives on the participant's PDS; atjam coordinates the round. (Design principle #1: don't host deliverables.)
Custom phases (beyond open / in-progress / closed)
A round's lifecycle has two layers, and they're easy to conflate:
- The derived state.
Round.deriveState(round)returns exactly one ofopen,in-progress,closed. It keys off just two milestone labels (signup-deadline→ in-progress,submission-deadline→ closed). This is the coarse status the state pill shows. - The timeline.
round.milestonesis an open list of{ label, date }. The known labels aresignup-deadline,submission-deadline,closing-event, andresults, but unknown labels are accepted — so you can add as many named phases as you like.
So custom phases are supported, as timeline data: add milestones with
your own labels. EPTSS runs a round through
signup-opens → voting-opens → covering-begins → closing-event:
milestones: [
{ label: "signup-opens", date: "2026-01-01T00:00:00Z" },
{ label: "voting-opens", date: "2026-01-08T00:00:00Z" },
{ label: "covering-begins", date: "2026-01-15T00:00:00Z" },
{ label: "submission-deadline", date: "2026-02-15T00:00:00Z" },
{ label: "closing-event", date: "2026-02-20T00:00:00Z" },
],
Every milestone renders as a station on the round's timeline automatically.
Keep a
submission-deadline.deriveStateonly closes a round once a milestone labelled exactlysubmission-deadlinehas passed. A round built from only custom labels derivesopenforever (this is why a live round with a finished-looking timeline can still read OPEN). Use your custom phases alongside the standardsignup-deadline/submission-deadlineso the coarse state stays meaningful.
Reading the current phase
To show which phase a round is in, use Round.deriveCurrentPhase. It reports
where now falls among the (date-sorted) milestones:
import { Round } from "@atjam/lexicons";
const phase = Round.deriveCurrentPhase(round);
// {
// index: number; // 0 before the first milestone; N after the last
// since?: Milestone; // the milestone that opened the current phase
// until?: Milestone; // the next upcoming milestone
// }
// Drive a phase label — e.g. a pill alongside the three-state badge:
const phaseLabel = phase.since?.label ?? "not started";
const nextUp = phase.until?.label; // undefined once the round is over
deriveCurrentPhase is pure and additive: it reads the same milestones
array and changes neither deriveState nor the wire format. The transit-line
UI already positions "now" between milestones this way — this exposes that
position as data for your own UI. See the API reference
for the exact signature.
Phase meaning lives in your app, not atjam
atjam derives where you are on the timeline; it does not assign meaning to
your phase labels. If "voting" should lock submissions, or "covering-begins"
should open uploads, that rule is domain logic — implement it in your app
against deriveCurrentPhase, the way EPTSS would. atjam stays the generic
coordination skeleton (design principle #4: one namespace per owner).
Designing your own subject and submission types
When adapting atjam to a new domain, you are really making two design choices.
The subject answers "what is everyone working on?" Define it as a record
under your own namespace with a clear $type. A writing jam might use
org.example.prompt; a book club might use org.example.book. Keep it
descriptive data — title, author, links — not coordination logic; the
coordination is atjam's job.
The submission payload type(s) answer "what does a finished deliverable look
like, and where does it live?" Prefer an existing record type on an app
that already does the job well — app.bsky.feed.post for short text,
com.whtwnd.blog.entry for long writing, fm.plyr.track for music. List those
NSIDs in acceptedSubmissionTypes. Only define a new deliverable lexicon if no
existing app fits, and even then publish it under your own domain.
Why atjam stays generic
It would be tempting to add a songTitle field to at.atjam.round, or to
enumerate accepted MIME types in the lexicon. atjam deliberately does not,
because:
- One namespace per owner (principle #4). If atjam encoded EPTSS's song
fields, every other adopter would inherit music-specific cruft, and EPTSS
would have to wait on atjam to evolve its own data. Instead EPTSS owns
site.eptss.songand ships it on its own schedule. - Open extension points use
unknown+$type(principle #3). Don't enumerate accepted types in the lexicon itself; let the round declare them as data (acceptedSubmissionTypes) and let subjects carry their own$type. New domains need no change toat.atjam.*. - Don't host deliverables (principle #1). By referencing payloads instead of storing them, atjam composes with every app on the network rather than competing with them.
The result: you can build a brand-new kind of jam — for any creative
discipline — by publishing one or two small lexicons under your own domain and
writing standard at.atjam.* records that point at them. atjam does not need
to know your domain exists.
See also
- Reading, writing, validating — the exact
write shapes for every record, including
subjectandpayload. - PUBLISHING.md — how
at.atjam.*is published; the same approach applies to your own domain's lexicons. - The package README's design principles.