Getting started
Tutorial. One guided path: read a real round and its signups, then write a jam, a round, and a signup. By the end you will have created a working open round on a PDS and signed up for it.
This tutorial uses the @atjam/lexicons TypeScript reference implementation, which is available as workspace:* inside the monorepo. If you are working in another language, read this for the shapes and flow, then write the same records with your own ATProto tooling — see Using the lexicons.
Before you start
You need:
- The
@atjam/lexiconspackage available in your workspace (it isprivate, so it is not on npm — see Using the lexicons). - For the read steps: nothing. atjam reads are unauthenticated public reads.
- For the write steps: an authenticated ATProto session. The web app uses OAuth (
@atcute/oauth-browser-client); the test harness uses an app password with@atproto/api. Either yields an agent that can callcom.atproto.repo.createRecord.
Step 1: Configure the read layer
The read layer talks to two services: a backlinks index (Constellation) and the PLC directory (for DID → PDS resolution). Configure them once at startup. Both default to canonical public instances, so you can skip this if the defaults are fine.
import { Queries } from "@atjam/lexicons";
Queries.setConstellationUrl("https://constellation.microcosm.blue");
Queries.setPlcDirectoryUrl("https://plc.directory");The web app does exactly this in web/app/root.tsx.
Step 2: Read a round and its signups
A round lives at an AT URI of the form at://<did>/at.atjam.round/<rkey>. Fetch the round record, then ask Constellation which signups link to it.
import { Queries } from "@atjam/lexicons";
// Parse the round's AT URI into its parts.
const { did, rkey } = Queries.parseAtUri(roundUri);
// Fetch the round record itself (a direct PDS read).
const round = await Queries.fetchRound(did, rkey);
console.log(round.value.assignment);
console.log(round.value.acceptedSubmissionTypes);
// Find every signup that strong-refs this round.
const signups = await Queries.fetchSignupsForRound(roundUri);
console.log(`${signups.links.length} signups`);fetchSignupsForRound returns a BacklinksResponse — a list of { uri, cid? } references. To read each signup's body, fetch it with Queries.getRecord (or use the higher-level fetchers for submissions and invitations, which fetch the bodies for you).
Step 3: Derive the round's state
State is not stored — it is derived from the round's milestones. Use the pure Round.deriveState helper:
import { Round } from "@atjam/lexicons";
const state = Round.deriveState(round.value); // "open" | "in-progress" | "closed""open" means signups are open. "in-progress" means the signup deadline has passed but the submission deadline has not. "closed" means the submission deadline has passed. A round with no deadlines stays "open".
Step 4: Write a jam and a round (as the organizer)
First you need an authenticated agent. Getting one is your ATProto client's job, not atjam's, and it differs by environment — this repo has two real examples: browser OAuth (web/app/lib/oauth.ts) and an app-password AtpAgent (tests/src/accounts.ts). Whatever you use, the result is something that can call com.atproto.repo.createRecord.
@atjam/lexicons ships no write helper, so the createRecord(...) below is the app's own thin wrapper over that call (web/app/lib/writes.ts) — it posts { repo, collection, record } and returns the new { uri, cid }. The record shapes are the part that matters; adapt the wrapper to your client.
import { NSIDS } from "@atjam/lexicons";
// 1. Create the jam (the container). Returns { uri, cid }.
const jamRef = await createRecord({
agent, // your authenticated agent
collection: NSIDS.jam, // "at.atjam.jam"
record: {
$type: NSIDS.jam,
name: "Old Salt Song Club",
createdAt: new Date().toISOString(),
},
});
// 2. Create a round under that jam. Strong-ref the jam by { uri, cid }.
const roundRef = await createRecord({
agent,
collection: NSIDS.round, // "at.atjam.round"
record: {
$type: NSIDS.round,
jam: jamRef, // { uri, cid } from step 1
assignment: "Make anything. Submit a Bluesky post as proof.",
acceptedSubmissionTypes: ["app.bsky.feed.post"],
milestones: [
{ label: "submission-deadline", date: daysFromNow(7) },
],
// joinMode omitted → resolves to "open": anyone can sign up.
createdAt: new Date().toISOString(),
},
});(daysFromNow is just any helper that returns an ISO date string, e.g. new Date(Date.now() + 7 * 864e5).toISOString().) The required round fields are jam, assignment, acceptedSubmissionTypes (at least one NSID), milestones (at least one), and createdAt.
Step 5: Sign up for the round (as a participant)
A different account writes a signup that strong-refs the round. For an open round, no invitation is needed.
import { NSIDS } from "@atjam/lexicons";
await createRecord({
agent, // the participant's agent
collection: NSIDS.signup, // "at.atjam.signup"
record: {
$type: NSIDS.signup,
round: { uri: roundRef.uri, cid: roundRef.cid },
note: "in, will use guitar",
createdAt: new Date().toISOString(),
},
});The signup button in the web app builds this exact record (web/app/components/signup-button.tsx).
End state
You now have, on real PDSes:
- a
at.atjam.jamrecord (the organizer's), - a
at.atjam.roundrecord under it (the organizer's), - a
at.atjam.signuprecord from the participant.
Read it back the way Step 2 did: fetchSignupsForRound(roundRef.uri) should return the signup. Note that backlinks travel through the firehose → indexer pipeline, so there is lag (typically a few seconds) before a freshly written signup shows up in Constellation. The test harness has a waitForBacklinks poller for exactly this (tests/src/reads.ts).
Where to go next
- Submit work, invite participants, and validate signups: Reading, writing, validating.
- Adapt atjam to your own domain (the EPTSS pattern): Extending atjam.
- Every exported function and type: API reference.