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:pdsThat 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 airspaceyour 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.tspnpm 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'spermissioned-databranch, or the@atproto/pdsspaces alpha. Hosted PDSes, includingbsky.social, do not support them yet. The API may change.pnpm dev:pdsruns 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
- OAuth and permission sets, to write on someone else's behalf instead of using an app password.
- publishing your lexicons, so other people can resolve your schemas.