getting started

A notes app, from an empty directory to a published record and a draft.

what you need

  • Node 22 or newer. The files below are run with node run.ts, which needs Node 24 for TypeScript; on 22, add --experimental-strip-types.
  • A PDS you can log into. Either run one locally, or use your own with an app password.

For a local one, clone the repository and start it:

git clone https://github.com/danielroe/airspace
cd airspace
pnpm install
pnpm dev:pds

That serves a PDS on http://localhost:2583 and prints credentials for two accounts, in dotenv shape. Keep it running.

Your own PDS works too. Create an app password in your account settings, and use your handle and https://bsky.social (or wherever your account lives) as the service. Public collections read and write; spaces do not, see below.

a project

mkdir notes && cd notes
pnpm init
pnpm pkg set type=module
pnpm add airspace

your lexicons

// lexicons.ts
import { defineLexicons, field, space } from 'airspace/lexicon'

export default defineLexicons('dev.example', {
  note: {
    title: field.text({ max: 120 }),
    body: field.markdown(),
    createdAt: field.datetime(),
  },
  workspace: space(['note']),
})

The first argument is your namespace, so note is dev.example.note. Use a namespace under a domain you control.

your collections

// collections.ts
import { defineCollections, defineSpace } from 'airspace'
import lexicons from './lexicons.ts'

export const { note: notes } = defineCollections(lexicons, {
  note: { sort: [['createdAt', 'desc']] },
})

export const workspace = defineSpace(lexicons.workspace, {
  collections: { notes },
})

a client

// notes.ts
import { createAirspace, passwordSession } from 'airspace'
import { workspace } from './collections.ts'

const session = await passwordSession({
  service: 'http://localhost:2583',
  identifier: process.env.PDS_IDENTIFIER!,
  password: process.env.PDS_PASSWORD!,
})

export const airspace = createAirspace({
  identity: { did: process.env.PDS_DID!, service: 'http://localhost:2583' },
  spaces: { workspace },
  session,
})

Against a public PDS, identity: 'you.example.com' is enough: the handle resolves to a DID and a PDS on the first call that needs it. A local PDS has no publicly resolvable handle, so pass { did, service }.

read and write

// run.ts
import { airspace } from './notes.ts'

await airspace.notes.create({
  title: 'Hello',
  body: '# hello\n\nfrom my own repo.',
  createdAt: new Date().toISOString(),
})

for (const note of await airspace.notes.list())
  console.log(note.rkey, note.value.title)
PDS_DID=did:plc:... PDS_IDENTIFIER=alice.test PDS_PASSWORD=hunter2 node run.ts

pnpm dev:pds prints all three.

Records are in your public repo now, readable by anyone with your DID.

your first draft

Spaces are experimental. They need a PDS running prerelease software: atproto's permissioned-data branch, or the @atproto/pds spaces alpha. Hosted PDSes, including bsky.social, do not support them yet. The API may change. pnpm dev:pds runs one that does support them.
const draft = await airspace.workspace.notes.create({
  title: 'Not public yet',
  body: 'still writing',
  createdAt: new Date().toISOString(),
})

await airspace.workspace.notes.publish(draft.rkey)

await airspace.workspace.supported() tells you whether this PDS serves spaces, so an app can hide the feature rather than fail at the first write.

next