Install
pnpm add airspaceThere's no codegen step, and no @atproto/lex dependency unless you bring existing lexicon JSON.
Describe your content model
Write your lexicons in TypeScript under your own namespace. The first argument is the namespace and every key is a short name, so project is dev.roe.project. Every field takes .optional() and .describe().
// lexicons.ts: definitions only, no runtime code
import { defineLexicons, field, space } from 'airspace/lexicon'
export default defineLexicons('dev.roe', {
projectCategory: {
name: field.text({ max: 64 }),
order: field.number().optional(),
},
project: {
description: 'A project on the /projects page.',
category: field.ref('projectCategory').describe('The parent category.'),
name: field.text(),
createdAt: field.datetime(),
},
location: {
key: 'self',
city: field.text(),
},
workspace: space(['project', 'projectCategory']),
})key defaults to tid; key: 'self' makes the record a singleton. field.ref('projectCategory') is a com.atproto.repo.strongRef, and a record in another namespace takes its full NSID. The other fields are text, markdown, number, boolean, datetime, url, enum, list, object, union, blob and image, each with a default maximum length or size, plus field.raw(...) for anything they don't cover.
field.text({ format }) takes any lexicon string format. @atproto/lex-schema validates datetime, uri, did, handle, nsid, tid, record-key, at-uri, at-identifier, cid and language; anything else, such as duration, is emitted to the JSON and validated as a plain string.
field.union([() => block]) is a closed union of typed object defs. .open() keeps the members you don't know:
export default defineLexicons('dev.roe', {
document: {
title: field.text(),
content: field.list(field.union([() => markdown, () => image]).open()),
},
})An unknown member reads back as { $type, ...its data } and survives a read-modify-write. Narrow with a member's own guard (markdown.isTypeOf(block)); a switch on $type can't narrow an open union.
Then describe what each collection means:
import { belongsTo, defineCollection, defineSpace } from 'airspace'
import lexicons from './lexicons.ts'
export const categories = defineCollection(lexicons.projectCategory, {
sort: [['order', 'asc']],
})
export const projects = defineCollection(lexicons.project, {
relations: { category: belongsTo(categories, 'category') },
})
// a `self` record key makes this a singleton
export const location = defineCollection(lexicons.location)
export const workspace = defineSpace(lexicons.workspace, {
collections: { projects, categories },
})The lexicon file carries no runtime code, which is why it is a separate file, but the collections do not have to be one call each. defineCollections takes the whole lexicon map and returns a collection per record, keyed by the lexicon's short name, with per-collection options optional. The callback form hands you the collections themselves, so a relation can name a sibling:
import { belongsTo, defineCollections, defineSpace } from 'airspace'
import lexicons from './lexicons.ts'
export const { project: projects, projectCategory: categories, location } = defineCollections(lexicons, c => ({
projectCategory: { sort: [['order', 'asc']] },
project: { relations: { category: belongsTo(c.projectCategory, 'category') } },
}))
export const workspace = defineSpace(lexicons.workspace, { collections: { projects, categories } })Spaces and permission sets in the map are skipped, since they are not collections. defineCollection stays for a model that wants one collection at a time, or a collection built from a schema lex build generated.
The lexicon primitives
airspace/lexicon also exports l: @atproto/lex-schema's builders with description accepted wherever the lexicon JSON allows one, plus record, token, space, openUnion and permissionSet defs. Use it for unions, tokens, knownValues, defaults or a shape the fields don't reach. Keys are full NSIDs, a bare value is the main def and an object is a set of named defs. Both styles compile to the same schemas, and field.raw(l.anything()) mixes them.
import { defineLexicons, l } from 'airspace/lexicon'
const badge = l.object({ label: l.string({ maxGraphemes: 32 }) })
export default defineLexicons({
'dev.roe.defs': { badge },
'dev.roe.project': l.record({
key: 'tid',
record: l.object({
name: l.string(),
state: l.string({ knownValues: ['draft', 'live'] }),
badge: l.optional(l.ref(() => badge)),
createdAt: l.string({ format: 'datetime' }),
}),
}),
'dev.roe.workspace': l.space({ key: 'literal:self', collections: ['dev.roe.project'] }),
})npx airspace lexicons emit writes the lexicon JSON, one file per NSID under lexicons/. Descriptions, maxGraphemes, knownValues, defaults, refs and unions all survive. Lexicon JSON has no inline object type, so a nested field.object becomes a def of its own, named after the field. Infer<typeof lexicons.project> is the record type.
Shared shapes such as community.lexicon.app.defs#image and com.atproto.repo.strongRef come from lex install <nsid> (in @atproto/lex, a devDependency), which vendors the schema and pins its CID; lex build then generates TypeScript. Reference the output directly: l.ref(() => AppDefs.image).
lex install writes the schema under lexicons/ and records its URI and CID in lexicons.json, dependencies included. Commit both: lex install --ci re-resolves every pinned NSID and exits non-zero if a CID has moved, which is the check to run in CI. An NSID only resolves if its authority publishes a _lexicon.<domain> DNS TXT record and a com.atproto.lexicon.schema record for it, so a schema whose author has not done that (com.whtwnd.blog.entry, at the time of writing) has to be vendored by hand from wherever they keep it.
If you already have lexicon JSON, keep it:
lex build --clear --ignore-invalid-lexicons --lib @atproto/lex-schemaThat generates TypeScript for everything except "type": "space" defs, which lex build can't parse yet. defineCollection(dev.roe.project.main) takes the output as is, and a space is defineSpace({ nsid: 'dev.roe.workspace', key: 'literal:self', collections: [...] }, { collections }). Pass --clear, or a second run fails with File already exists. Pass --lib so the generated code imports @atproto/lex-schema, which airspace already ships, rather than @atproto/lex. Add --import-ext .ts if you run the generated files through Node's own TypeScript support rather than a bundler.
Read and write
createCMS returns synchronously and resolves your identity (handle to DID to PDS) on the first call that needs it, so export const cms = createCMS(...) works at module level. A failed resolution is retried on the next call. Reads are unauthenticated; writes need a session.
import { createCMS, passwordSession } from 'airspace'
const session = await passwordSession({ service: 'https://pds.example', identifier: 'roe.dev', password: appPassword })
export const cms = createCMS({
identity: 'roe.dev',
collections: { location },
spaces: { workspace }, // its `projects` and `categories` are public collections too
session, // omit for read-only; an OAuth session goes in the same slot
})
const featured = await cms.projects.list({ with: ['category'], limit: 5 })
featured[0].related.category?.value.name
const one = await cms.projects.get(rkey) // null when it doesn't exist
await cms.projects.create({ name: 'npmx', category: ref, createdAt: now })
await cms.projects.put(rkey, value)
await cms.projects.delete(rkey)
await cms.location.get() // singletons take no rkey
await cms.location.put(value)A collection inside a space is a public collection too, since publish() writes it to the public repo, so collections only has to name the ones no space uses. cms.projects and cms.workspace.projects are the two ends of one collection. An explicit entry wins, and two spaces that give one name to different collections is an error.
belongsTo(target, field) takes either shape of pointer: a com.atproto.repo.strongRef ({ uri, cid }) or a plain at-uri string. Anything that isn't an at:// URI resolves to null.
hasMany(target, field) does the same over a list field, which is how tags are usually modelled:
export const bookmarks = defineCollection(lexicons.bookmark, {
relations: { tags: hasMany(tags, 'tags') },
})
const [bookmark] = await cms.bookmarks.list({ with: ['tags'] })
bookmark.related.tags.map(tag => tag.value.name) // an array, never nullA ref that doesn't resolve is dropped from the array. Either relation costs one listing of the target collection, however many records point at it.
A record you do not have a collection for is one call away:
await cms.resolve(uri, projects) // typed and validated, null when it does not exist
await cms.resolve(uri) // any repo, any space, `value` is `unknown`Pass identity: { did, service } when the handle isn't publicly resolvable, such as a local development PDS, or when you want a build to make no identity requests. Because resolution is lazy, everything derived from the DID or PDS URL is async: await cms.identity(), await cms.blobs.url(blob), await cms.workspace.uri().
An app-password session is await passwordSession({ service, identifier, password }), exported from airspace and loading @atproto/lex-password-session on first call, so it costs nothing in a read-only build; an OAuth one comes from airspace/oauth below. Either goes in as session.
Records are plain JSON, with no serialisation step. A blob ref arrives as { $type: 'blob', ref: { $link }, mimeType, size }.
Records come back newest first, because a tid record key sorts by creation time. Pass sort for anything else.
Only limit reaches the PDS, and only when the query has no where, sort or offset, including the collection's default sort. Everything else in ListQuery is applied in memory over the full listing, because a cursor and reverse are all listRecords offers.
page() is list() with the PDS's own surface and nothing on top:
const { records, cursor } = await cms.projects.page({ limit: 50 })
await cms.projects.page({ limit: 50, cursor }) // the next page; `cursor` is absent on the last one
await cms.projects.page({ reverse: true }) // oldest firstSpaces page identically: com.atproto.space.listRecords takes the same cursor and the same reverse.
Concurrent and repeated writes
put and delete take the CID you last saw, so a second editor cannot silently overwrite the first. A mismatch throws ConflictError, which names the collection, the record key and the CID the PDS rejected.
const project = await cms.projects.get(rkey)
await cms.projects.put(rkey, value, { ifMatch: project.cid })
await cms.projects.delete(rkey, { ifMatch: project.cid })ifChanged reads the record, compares the prepared value and skips the write if nothing changed, so a build that runs on every push doesn't rewrite every record.
const { changed } = await cms.projects.put(rkey, value, { ifChanged: true })ifMatch is a type error inside a space, because com.atproto.space.putRecord takes no swap parameter. ifChanged works in both.
Several writes, one commit
batch sends one applyWrites, so either all of it lands or none of it does. Every value is validated, and every write plugin runs, before anything is sent.
const results = await cms.batch((b) => {
b.categories.create({ name: 'Frameworks', createdAt: now })
b.projects.create({ name: 'Nuxt', category: ref, createdAt: now })
b.projects.delete(oldRkey)
})Results come back in order, each carrying operation, collection and rkey, plus uri and cid for anything that wrote. Inside a batch, put is an update and the record must exist; use create with an explicit rkey otherwise. cms.<space>.batch does the same in a space.
migrate rewrites a collection after a lexicon change and reports what it did:
const report = await cms.projects.migrate(value => ({ ...value, slug: slugify(value.name) }), { dryRun: true })
// { scanned: 120, changed: 118, unchanged: 2, failed: { '3kabc...': [{ path: 'slug', message: '...' }] } }Records whose transformed value fails validation are named in failed and left alone; the rest are written in batches of 200.
The scan itself doesn't validate. After adding a required field every existing record fails the new schema, so list() returns nothing and get() throws until they're rewritten; migrate reads them anyway and validates only what your transform produced. It works the same inside a space, and running it twice is safe.
Reads are cached if you ask for it, and identical in-flight reads always share one request:
const cms = createCMS({ identity: 'roe.dev', collections, cache: { ttl: 60_000 } })
cms.invalidate() // everything
cms.invalidate('dev.roe.project') // one collection; writes already do this for their ownPass storage and the cache survives a restart and is shared between processes. It takes any unstorage driver, and records go through the IPLD JSON codec, so blob refs and CIDs come back as themselves. A read the storage can't answer falls through to the PDS.
import { createStorage } from 'unstorage'
import fsDriver from 'unstorage/drivers/fs'
const cms = createCMS({
identity: 'roe.dev',
collections,
cache: { ttl: 300_000, storage: createStorage({ driver: fsDriver({ base: '.cache/airspace' }) }) },
})Live updates
airspace/live watches a repo over Jetstream and calls you back for every write. It uses the global WebSocket and no Node built-ins, so the same call runs in a browser, a worker or on a server.
import { subscribe } from 'airspace/live'
const stop = subscribe({
did: 'did:plc:jbeaa5kdaladzwq3r7f5xgwe',
collections: ['dev.roe.project'],
onCommit: ({ operation, rkey, record }) => console.log(operation, rkey, record),
})It reconnects with the last timeUs it saw as the cursor, so a dropped socket replays what it missed. Pass retry: 0 to handle that yourself, service for another Jetstream instance, and cursor to resume across a restart.
invalidateOn wires that into a cache:
import { invalidateOn } from 'airspace/live'
const stop = invalidateOn(cms, { did: await cms.identity().then(i => i.did), collections: ['dev.roe.project'] })A write from anywhere, including another process or another device, drops that collection from the cache. Writes made through this CMS already drop their own.
Drafts in a space
A permissioned space is atproto's answer to data that shouldn't be public. A space collection has the same typed surface as a public one:
const draft = await cms.workspace.projects.create({ name: 'Not public yet', category: ref, createdAt: now })
await cms.workspace.projects.publish(draft.rkey) // copies into the public repo at the same key
await cms.workspace.projects.publish(draft.rkey, {
transform: value => ({ ...value, publishedAt: now }),
})
await cms.workspace.projects.publish(draft.rkey, { ifMatch: draft.cid })
const live = await cms.workspace.projects.published() // the keys that exist in bothRefs between two drafts are stored as at://author/collection/rkey, so a published copy carries the same refs its draft did.
A published copy is byte-identical to its draft and has the same CID, so publish(rkey, { ifMatch: draft.cid }) means "only if nobody has edited the public record since".
Not every PDS serves spaces yet. await cms.workspace.supported() answers in one call, cached per PDS, so an app can hide the feature rather than fail at the first write. A space call against a PDS without them throws SpacesUnsupportedError.
cms.workspace.manage covers com.atproto.simplespace: info(), ensure(), update(), delete() and members. Reading and writing are governed separately, each by one of 'public', 'member-list' or { managingApp: did }:
await cms.workspace.manage.ensure({ read: 'member-list', write: 'member-list', appAccess: 'open' })
await cms.workspace.manage.update({ read: 'public' })
await cms.workspace.manage.members.add(did) // read and write, unless you pass { read, write }
await cms.workspace.manage.members.list() // [{ did, read, write }]You only need ensure() for a shared space, where the policies and the member list matter. Writing to your own personal space creates it, and info() returns null until something calls createSpace, even though com.atproto.space.listSpaces already lists it.
Blobs uploaded into a space are currently readable through the public sync.getBlob endpoint (atproto#5435). Don't put images in a space that you'd mind being seen.Blobs and images
const { blob, cid, mimeType, size, aspectRatio } = await cms.blobs.upload(file, { maxBytes: 2_000_000 })
await cms.projects.put(rkey, { ...value, images: [{ alt: 'Screenshot', image: blob }] })
await cms.blobs.image(project.value.images?.[0]) // { url, alt, width, height }
await cms.blobs.image(project.value.cover) // a bare blob field works too, with an empty altImage dimensions come from the file header, without decoding; the parser only loads when you call upload(). The token values of a community.lexicon.app.defs array live on the generated constants: community.lexicon.app.defs.purposeScreenshot.value.
Plugins
A plugin is { name, read?, write? }. read returns data for record.meta, which is typed by inference from the plugins you register.
import { definePlugin } from 'airspace'
import { markdown } from 'airspace/plugins/markdown' // optional peer: comark
import { timestamps } from 'airspace/plugins/timestamps'
const posts = defineCollection(dev.roe.post.main, { plugins: [markdown('body')] })
const cms = createCMS({ identity: 'roe.dev', collections: { posts }, plugins: [timestamps()] })
const [post] = await cms.posts.list()
post.meta.markdown?.nodes // typedtimestamps() sets createdAt on create and updatedAt on put, and skips collections whose schema has no such field, so it's safe to register CMS-wide. Mark those fields optional in the lexicon, because a write plugin can't relax the input type.
Write plugins run on publish() too.
Server-rendered frameworks
Load records on the server and return them from a Nuxt useAsyncData, a SvelteKit load, an Astro page or an RSC.
Two things stay on the server. cms.blobs.image() needs the PDS URL, so resolve images in the loader and send ResolvedImage to the client rather than the raw blob ref. And a session is a server concern, so keep the writing CMS in a server route.
Workers and other edge runtimes
airspace, airspace/lexicon, airspace/live and the plugins bundle for Cloudflare Workers, Deno and the browser with no Node built-in in the graph. Handle resolution loads node:dns with a dynamic import(), and falls back to DNS over HTTPS (cloudflare-dns.com) for the _atproto TXT lookup where there is no such module. Passing identity: { did, service } does no resolution at all.
airspace/oauth will not bundle: @atproto/oauth-client-node needs node:crypto, node:net and node:dns. Run the OAuth handshake on a Node runtime, or use @atproto/oauth-client-browser and hand the resulting session to createCMS.
OAuth
scopesFor derives the scopes your model needs, before any session exists.
import { scopesFor } from 'airspace'
import { createOAuth } from 'airspace/oauth' // optional peer: @atproto/oauth-client-node
const oauth = await createOAuth({
baseUrl: 'https://roe.dev',
redirectPath: '/api/admin/auth/callback',
name: 'roe.dev admin',
scopes: scopesFor({ collections: { projects, categories }, spaces: { workspace } }),
stores: { session: mySessionStore },
allowHttp: true, // only for a local http PDS
})Against a local PDS, allowHttp isn't enough on its own: @atproto/oauth-client-node resolves the handle through DNS and the DID through plc.directory, and neither knows about alice.test. Point both at the development network:
const local = {
allowHttp: true,
handleResolver: 'http://localhost:2583', // the PDS serves `resolveHandle`
plcDirectoryUrl: 'http://localhost:41937', // the port `pnpm dev:pds` prints
}blob: scopes are narrowed to the MIME patterns your blob fields accept, so a model whose images are all accept: ['image/*'] asks for blob:image/* rather than blob:*/*.
Permission sets
A published lexicon family can declare its whole OAuth surface as one "type": "permission-set" def, so the consent screen shows one line instead of a scope list. Declare yours next to the records it covers:
import { defineLexicons, field, permissions } from 'airspace/lexicon'
export default defineLexicons('dev.roe', {
project: { name: field.text(), cover: field.image().optional() },
projectCategory: { name: field.text() },
authFull: permissions({
collections: ['project', 'projectCategory'],
title: 'Manage projects',
detail: 'Read and write your projects and their categories.',
}),
})airspace lexicons emit and airspace lexicons publish handle it like any other def. Everything a set grants has to sit under its own authority, because a consumer's include: drops the rest. airspace throws at definition time rather than publishing a set that grants less than it says.
On the consuming side, pass the set instead of letting scopesFor derive a scope per collection:
scopesFor({ collections: { projects, categories }, include: [lexicons.authFull] })
// ['atproto', 'include:dev.roe.authFull', 'blob:image/*']Someone else's set works too, by NSID: include: ['site.standard.authFull'] covers every collection under site.standard.. Blob permissions are not part of an include:, so a blob: scope is still emitted alongside.
A set has to be published before anyone can ask for it. The authorization server resolves the NSID at authorize() time, and until airspace lexicons publish has written the schema and _lexicon.<domain> points at your DID, the request fails with invalid_scope: Could not resolve Lexicon for NSID. The same is true of a space: scope. Derive scopes from the collections while developing and switch to include: once the lexicons are published.
Serve oauth.metadata at /oauth-client-metadata.json, redirect to await oauth.authorize(handle), and on return const { session } = await oauth.callback(params). That session goes straight into createCMS({ session }).
Publish your lexicons
Publishing your lexicons as com.atproto.lexicon.schema records lets anyone else resolve them.
airspace lexicons publish --identity roe.dev --dry-run # no credentials needed
AIRSPACE_APP_PASSWORD=... airspace lexicons publish --identity roe.devpublish reads ./lexicons.ts (a defineLexicons module) or, failing that, ./lexicons/ (JSON); --lexicons points it elsewhere. The authority defaults to your reversed handle (roe.dev becomes dev.roe), and only lexicons under an authority you own are written, so vendored community schemas are never republished under your DID. The command prints the plan and the _lexicon.<domain> TXT records third parties need.
Validation
The PDS can't validate lexicons it doesn't know, particularly inside spaces (atproto#5433), so airspace validates every write against the schema before sending it.
const result = await cms.projects.validate(value) // { ok: true } | { ok: false, issues }validate() runs the whole write path, write plugins and the space ref rewrite included, which is why it's async.
Writes throw ValidationError, which extends AirspaceError, names the collection and record key, and carries the normalised issues. Two more errors extend AirspaceError: ConflictError (an ifMatch the PDS rejected, carrying collection, rkey and cid) and ScopeError (a session whose grant predates a collection in your model, carrying missingScope; the user has to authorize again). All of them are exported from airspace, so you never catch a @atproto/lex error.
Lexicons are open, so unknown fields you write are kept. If your input comes from a form, validate it yourself before it reaches create().
Reads are validated too. get() throws ValidationError naming the record. list() skips a record the schema rejects, so one record left over from an older shape doesn't empty a page.
An escape hatch for bundle-sensitive code
Importing lexicons.ts puts @atproto/lex-schema and every one of your field definitions in the bundle. If that matters more than validation does, model carries the NSIDs and nothing else, with the types coming from an import type that disappears at build time.
import type lexicons from './lexicons.ts'
import { defineCollection, model } from 'airspace'
const lex = model<typeof lexicons>('dev.roe')
export const notes = defineCollection(lex.note)The namespace is an argument because a type carries nothing into the running program; pass none when your keys are already NSIDs. What you give up:
- Nothing is validated client side.
validate()is absent from the type, and writes into a space are validated by nobody, because the PDS cannot validate a lexicon it does not know (atproto#5433). scopesForcannot see your blob fields, so addblob:*/*(or the pattern you accept) yourself if you upload.- Singletons are a type-level fact only. The type enforces
get()overget(rkey), but at runtime both work, withselfas the key when you pass none.
Everything else in this README assumes the validating path.
Bundle size
Minified and gzipped, measured by pnpm size: esbuild, code-split, optional peers (comark, @atproto/oauth-client-node) external. "Lazy" chunks only load when spaces, blobs.upload() or passwordSession() are used.
| Import | airspace only | with runtime deps |
|---|---|---|
airspace | 9.2 kB (+5.5 kB lazy) | 36.2 kB (+10.5 kB lazy) |
airspace/lexicon | 3.3 kB | 22.7 kB |
airspace/live | 0.7 kB | 0.7 kB |
airspace/oauth | 0.7 kB | 0.7 kB |
airspace/plugins/markdown | 0.3 kB | 0.3 kB |
airspace/plugins/timestamps | 0.2 kB | 0.2 kB |
The runtime dependencies are @atproto/lex-schema (validation), @atproto/lex-client (XRPC), @atproto/lex-data and @atproto/lex-json (record values to and from JSON, with multiformats for CIDs behind them), @atproto/lex-password-session and image-meta. The last two are only ever in the lazy column: passwordSession() and blobs.upload() are the only things that load them.
Examples
The same notes app in four frameworks, on the same lexicons.
examples/nuxt: a Nitro plugin that owns the CMS singleton, server routes,@comark/vue.examples/astro: static generation,getStaticPaths, and Astro actions for the write path.examples/sveltekit:loadfunctions and form actions with field-level validation.examples/node: a CLI with no framework and no build step, one subcommand per page.
The demo on getair.space is the same app, running against a sandbox account on our PDS.