atjamalpha

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 pointOn recordShapeWhat it carries
subjectat.atjam.roundunknown ($type-discriminated)The structured subject of the round.
closingEventat.atjam.roundunknown ($type-discriminated)An optional closing event (listening party, demo day).
acceptedSubmissionTypesat.atjam.roundstring[] of NSIDsWhich deliverable record types this round accepts.
payloadat.atjam.submissionStrongRefThe 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 subject field's own description in round.json, and the observed shape of a live EPTSS round. Treat the EPTSS-side code as conceptual; only the at.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.songnot 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 of open, 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.milestones is an open list of { label, date }. The known labels are signup-deadline, submission-deadline, closing-event, and results, 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. deriveState only closes a round once a milestone labelled exactly submission-deadline has passed. A round built from only custom labels derives open forever (this is why a live round with a finished-looking timeline can still read OPEN). Use your custom phases alongside the standard signup-deadline / submission-deadline so 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.song and 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 to at.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