Lexicons are AT Protocol's schema language: a JSON document that defines a
record type, an XRPC endpoint, or a set of shared definitions, named by an
NSID (com.example.post). They are how the protocol stays extensible —
anyone who controls a domain can define new types, publish them as records
on their PDS, and anyone else can fetch and validate against them.
Exosphere covers the whole lifecycle:
- Author — validate documents against the lexicon meta-rules
- Lint — spec errors plus style warnings, before anything is published
- Type-check — validate records against any lexicon at runtime, with
the spec's permissive semantics (or
strict: true) - Publish — write lexicons to
com.atproto.lexicon.schemaon a PDS - Fetch — pull published lexicons back by repo or by NSID authority
- Generate — compile-time typed modules from your own or anyone's lexicons
The document
A lexicon is a single JSON object with a version, an NSID, and definitions:
{
"lexicon": 1,
"id": "com.example.post",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"required": ["text", "createdAt"],
"properties": {
"text": { "type": "string", "maxGraphemes": 600 },
"createdAt": { "type": "string", "format": "datetime" },
"subject": { "type": "ref", "ref": "com.atproto.repo.strongRef" },
"tags": { "type": "array", "items": { "type": "string", "maxGraphemes": 32 } }
}
}
}
}
}Lexicon.Schema.new/1 parses and validates the document, enforcing the
rules the spec places on lexicons themselves — version 1, a simple NSID
id with no fragment, at most one primary definition per file, record
definitions carrying a key (tid, nsid, any, or literal:<value>),
and refs never targeting refs, unions, or tokens:
{:ok, schema} = Exosphere.Lexicon.Schema.new(json)
{:error, errors} = Exosphere.Lexicon.Schema.new(broken_json)
# [{"defs.main.key", "invalid key format \"banana\"; expected tid, nsid, any, or literal:<value>"}]The struct round-trips losslessly: Schema.to_record/1 produces the wire
record (injecting $type), Schema.from_record/1 decodes one back, and
the defs you put in are the defs you get out. Schema.record_key/1 is
the NSID itself — a published lexicon lives at
at://<did>/com.atproto.lexicon.schema/<nsid>.
Linting
Before publishing — the artifact is permanent once others depend on it — lint the documents:
$ mix exosphere.lint.lexicons priv/lexicons
linted 2 lexicons, 0 error(s) 0 warning(s)
Errors are spec violations (the same rules Schema.new/1 enforces, plus
invalid JSON and parser-level node checks). Warnings are style-guide
suggestions: definitions and record properties without descriptions,
and format values outside the spec set — a typo like "datetim" is
otherwise silently never checked. --strict fails on warnings too; the
task takes individual files or directories and exits non-zero on errors.
Registering and type-checking
The runtime registry maps NSIDs to parsed lexicons and backs validation:
{:ok, _} = Exosphere.Lexicon.Registry.load_vendored() # bsky + community + curated atproto
:ok = Exosphere.Lexicon.register(schema) # your own (parsed, %Schema{}, or raw JSON)
:ok = Exosphere.Lexicon.validate("com.example.post", %{
"$type" => "com.example.post",
"text" => "hello",
"createdAt" => "2026-08-25T12:00:00.000Z"
})A host app with its own lexicon directory loads it in one call:
{:ok, _} = Exosphere.Lexicon.Registry.load_dir("priv/lexicons")Validation semantics
Lexicon.Validator implements the spec's permissive-by-default posture:
- Unknown fields are ignored — schema evolution means readers see
fields they don't know yet.
strict: truerejects them. - Unions are open — a
$typeoutside the declared refs passes as long as the value satisfies the data model. Closed unions ("closed": true) always reject unknown variants;strict: truerejects them in open unions too. enumis closed;knownValuesis advisory and never fails.maxLengthcounts UTF-8 bytes,maxGraphemescounts grapheme clusters — the distinction matters the moment text isn't ASCII.- Required fields may be null only when listed in
nullable— omitted, null, and empty are three different states. - Format checks cover all spec formats:
datetime,uri,at-uri,nsid,did,cid,handle,language,tid,record-key,at-identifier.
One permissive behavior deserves its own headline: a ref whose target
lexicon is not registered is skipped — that field passes unchecked.
Register the referenced lexicons (the vendored corpus covers the
protocol's own types, com.atproto.repo.strongRef included) or run with
strict: true, which makes unresolved refs — and unresolved union
variants — errors:
{:error, [{"subject", "unresolved ref com.atproto.repo.strongRef (lexicon not registered)"}]} =
Exosphere.Lexicon.validate(nsid, record, strict: true)To check a schema you haven't registered (a fetched document, say)
without touching global state, use Lexicon.validate_with/2:
:ok = Exosphere.Lexicon.validate_with(schema, record)The registry lives in :persistent_term: no process to supervise, reads
are free, and it suits a load-once schema set. Exosphere.Lexicon.Registry.validate/3
also takes optimistic: true, mirroring the PDS "fail-open" mode —
unregistered NSIDs pass rather than error.
Errors are [{path, message}] tuples with dotted paths and array
indices ("tags[0]", "subject.uri"), directly renderable in forms or
API responses.
Publishing
With an OAuth session (see the OAuth guide), publishing is one call:
{:ok, %{uri: uri, cid: cid}} = Exosphere.Lexicon.publish(session, schema)
# uri: "at://did:plc:.../com.atproto.lexicon.schema/com.example.post"publish/2 takes the PDS URL and DID from the session
(session.pds / session.sub); publish/4 accepts them explicitly.
The record key is the lexicon's NSID, so publishing a revised document
for the same NSID updates the record in place — the natural unit of
modification. Lexicon.delete(session, nsid) removes it.
Modifying without breaking consumers
The spec's evolution rules are strict, and for good reason — records already written against the old schema keep existing:
- New fields must be optional (never added to
required). - Fields cannot be renamed or change type; deprecate instead of removing.
- Union variants can be added to an open union, never removed.
- A closed union's variant set is fixed.
- Anything else — including narrowing constraints in ways existing records violate — requires a new NSID.
A safe update loop fetches what is published, applies your changes, and re-publishes:
{:ok, current} = Exosphere.Lexicon.Resolver.fetch(session.pds, session.sub, "com.example.post")
{:ok, revised} = Exosphere.Lexicon.Schema.new(update(current.defs))
{:ok, _} = Exosphere.Lexicon.publish(session, revised)Exosphere does not yet diff revisions for you — breaking-change detection is on the roadmap — so for now the rules above are the checklist.
Fetching and resolving
Lexicons published by any repository can be fetched back:
# From a known repo; register: true also puts it in the registry
{:ok, schema} =
Exosphere.Lexicon.Resolver.fetch(pds_url, did, "com.example.post", register: true)
# Everything a repo publishes
{:ok, %{schemas: schemas, invalid: []}} = Exosphere.Lexicon.Resolver.list(pds_url, did)
# Via NSID authority: DNS TXT _lexicon.<reversed-authority-domain> → DID → PDS
{:ok, schema} = Exosphere.Lexicon.Resolver.resolve("com.example.post")Authority resolution follows the lexicon resolution spec: it is deliberately non-recursive (no probing up or down the DNS tree — a failed TXT lookup is a failed resolution), and DNS changes are not announced on the firehose, so don't cache authority results long-term.
Compile-time modules
For lexicons you control — or third-party ones you've vendored — the
generator produces typed struct modules with new/1 (build + validate),
to_map/1 (wire encoding with $type), from_map/1, and type_id/0.
The library's own app.bsky.*, community.lexicon.*, and curated
com.atproto.* modules are generated this way from priv/lexicons.
A host app generates modules for its own lexicons without touching the library's tree:
$ mix exosphere.gen.lexicons --dir priv/lexicons \
--out lib/my_app --namespace MyApp --map com.example=Schemas
--dirscopes generation to that directory; the library's vendored corpus is still parsed alongside, and refs into it (likecom.atproto.repo.strongRef) point at the library's compiled modules rather than duplicating them under your namespace.--map com.example=Schemasstrips the authority segments the way the built-in rules do forapp.bsky:com.example.postbecomesMyApp.Schemas.Post, notMyApp.Com.Example.Post.- To vendor a repo's published lexicons first:
mix exosphere.gen.lexicons --from did:plc:abc123.
Generation is deterministic — regenerate and git diff shows exactly
what the schema change bought you.
What the JSON can't say
Lexicons constrain types and bounds, not relationships between fields.
"A post has a URL or a text body or both" cannot be expressed, so a
generated module's new/1 will happily build a post with neither. The
pattern that works: generate (or register) the schema for the wire
contract, and hand-write the constructor that enforces your invariants
on top:
defmodule MyApp.Post do
alias MyApp.Schemas.Post, as: Schema
def new(attrs) do
with :ok <- validate_invariants(attrs),
{:ok, post} <- Schema.new(attrs) do
{:ok, post}
end
end
endThe generated (or registry-validated) layer still buys you everything the schema can say — lengths, formats, refs, unions — and your hand-written layer says the rest.
Reference
| Module | Purpose |
|---|---|
Exosphere.Lexicon | Facade: register/1, validate/3, validate_with/2, publish/2, delete/2 |
Exosphere.Lexicon.Schema | The com.atproto.lexicon.schema record type; meta-rule validation |
Exosphere.Lexicon.Validator | Runtime validation of wire values against parsed IR |
Exosphere.Lexicon.Registry | NSID → lexicon registry (:persistent_term) |
Exosphere.Lexicon.Resolver | Fetch by repo (getRecord, listRecords) or NSID authority (DNS TXT) |
Exosphere.Lexicon.Parser | Lexicon JSON → normalized IR |
Exosphere.Lexicon.Generator | Typed module generation (base:, seeds:, rules:, external:) |
Mix.Tasks.Exosphere.Lint.Lexicons | mix exosphere.lint.lexicons |
Mix.Tasks.Exosphere.Gen.Lexicons | mix exosphere.gen.lexicons |
Mix.Tasks.Exosphere.Lexicons.Sync | mix exosphere.lexicons.sync (vendored corpus refresh) |