# Lexification Server

> Migrate a service package off the legacy server stack — `lex gen-server` codegen, a committed `src/lexicon/` tree, `server.<ns>.<method>()` route chains, `@atproto/api` inside service code — onto `@atproto/lex` and `@atproto/xrpc-server`. Use when asked to lexify, modernize, or migrate a server package; when a route file imports `Server` or `createServer` from `../lexicon/index.js`; when replacing `ids.*` constants, generated `isX()` guards, `server.xrpc.router`, or `QueryParams`/`OutputSchema` type imports; and when a package still runs `lex gen-server` in its `codegen:lex` script. For the calls a service makes *out* to other services, use lexification-client. For writing new routes on an already-migrated package, use xrpc-server.

- Skill: `bluesky-social/lexification-server` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add bluesky-social/lexification-server`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bluesky-social/lexification-server/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: bluesky-social (https://skillmd.com/u/bluesky-social)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bluesky-social/lexification-server

---


# Lexification: migrating a service package

Moves a package from `@atproto/lex-cli`'s `lex gen-server` output to
`@atproto/lex` schemas plus `@atproto/xrpc-server` routing.

Every service in this repo — `packages/pds`, `packages/bsky`, `packages/ozone` —
is already migrated, so this skill now applies to packages outside the repo, or
to a stray route chain left behind. **Read the migrated services before
inventing a pattern.**

## Which skill applies

| Situation                                                    | Skill                                                  |
| ------------------------------------------------------------ | ------------------------------------------------------ |
| Package has `src/lexicon/` (singular) and route chains       | **this skill**                                         |
| Adding a route to a package already on `server.add()`        | [xrpc-server](../xrpc-server/SKILL.md)                 |
| The same package's outbound `AtpAgent` / `agent.api.…` calls | [lexification-client](../lexification-client/SKILL.md) |

A service migration almost always needs this skill _and_
lexification-client: one package both serves routes and calls other services.
Do the server half first — it forces `src/lexicons/` into existence, which the
client half then imports from.

## Guiding principles

- **Type-level changes over runtime changes.** Working runtime logic stays;
  what changes is where types and constants come from. A migration diff that
  alters behavior is a bug in the migration.
- **Migrate one namespace of route files at a time**, building in between.
  A route file is self-contained, so a broken one doesn't block the rest.
- **Leave test call sites on `AtpAgent`.** Tests double as the regression
  check that the migration didn't change behavior — swapping the client they
  use destroys that signal. Tests that import _types or constants_ from the
  deleted `src/lexicon/` do have to move (details below).

## Order of work

1. **Project config** — swap codegen, deps, gitignore, delete `src/lexicon/`.
   → [references/project-config.md](references/project-config.md)
2. **Server bootstrap** — `createServer`, `Server` type, Express mount.
3. **Route files** — `server.<ns>.<method>(…)` → `server.add(schema, …)`.
   → [references/routes.md](references/routes.md)
4. **Constants and guards** — `ids.*`, `isX()`, token exports, `$build`.
   → [references/types-and-guards.md](references/types-and-guards.md)
5. **Outbound calls** — [lexification-client](../lexification-client/SKILL.md).
6. **Branded types** at function/interface/DB boundaries.
   → [references/types-and-guards.md](references/types-and-guards.md)

Steps 1–2 are a single non-splittable commit (the package won't compile
in between). Steps 3–6 are incremental.

## Server bootstrap

The only structural change in the whole migration. `createServer` and `Server`
stop coming from generated code:

```diff
- import { createServer } from './lexicon/index.js'
+ import { createServer } from '@atproto/xrpc-server'

- let server = createServer({
+ const server = createServer([], {
    validateResponse: config.debugMode,
    payload: { /* … */ },
  })

- app.use(server.xrpc.router)
+ app.use(server.router)
```

The new first argument is an array of legacy `LexiconDoc`s for routes not
backed by a generated schema; schema-based setups pass `[]`. Option names
(`validateResponse`, `payload`, `catchall`, `rateLimits`, `errorParser`) are
unchanged — see [packages/pds/src/index.ts](../../../packages/pds/src/index.ts).

In route files, `Server` comes from the same package:

```diff
- import { Server } from '../../../../lexicon/index.js'
+ import type { Server } from '@atproto/xrpc-server'
```

## Import mapping

| Before                                                 | After                                                                |
| ------------------------------------------------------ | -------------------------------------------------------------------- |
| `../lexicon` (`Server`, `createServer`)                | `@atproto/xrpc-server`                                               |
| `../lexicon/lexicons` (`ids`)                          | `../lexicons/index.js` → `.$type` / `.$lxm`                          |
| `../lexicon/types/…` (record & def types)              | `../lexicons/index.js` → `.Main`, `.ViewRecord`, …                   |
| `../lexicon/types/…` (`QueryParams`, `OutputSchema`)   | `../lexicons/index.js` → `.$Params`, `.$OutputBody`                  |
| `../lexicon/types/…` (`isX`, `validateX`)              | `../lexicons/index.js` → `.$isTypeOf`, `.$matches`, `.$safeValidate` |
| `../lexicon/util` (`$Typed`, `Un$Typed`)               | `@atproto/lex`                                                       |
| `@atproto/api` (`AtpAgent`)                            | `@atproto/lex` (`Client`)                                            |
| `@atproto/lexicon` (`jsonStringToLex`, `stringifyLex`) | `@atproto/lex` (`lexParse`, `lexStringify`)                          |
| `@atproto/lexicon` (`BlobRef`)                         | `@atproto/lex` (`BlobRef`)                                           |
| `@atproto/xrpc` (`HeadersMap`)                         | `@atproto/xrpc-server` (`Headers`, usually aliased)                  |
| `@atproto/xrpc` (`XRPCError`, thrown by a _caller_)    | `@atproto/lex` (`XrpcError`)                                         |
| `multiformats/cid` (`CID`)                             | `@atproto/lex` (`Cid`, `parseCid`)                                   |

`@atproto/xrpc-server` keeps its own `XRPCError` for errors handlers **throw**.
The casing distinguishes them: `XRPCError` server-side (concrete, thrown),
`XrpcError` client-side (abstract, caught). Both can appear in one file.

`@atproto/lex` re-exports `lex-client`, `lex-schema`, `lex-data`, and
`lex-json`, so `Cid`, `BlobRef`, `lexParse`, and `$Typed` all resolve from it.
`@atproto/lex-cbor` is **not** re-exported. Importing from either the umbrella
or the sub-package is fine; match whatever the file already does.

## Pitfalls

- **`$type` vs `$lxm` vs `$nsid`.** `$type` is the record/object type string
  (`app.bsky.feed.post.$type` → `'app.bsky.feed.post'`), used for collections
  and union tags. `$lxm` is the method id, used in auth and proxy checks.
  `$nsid` is the raw NSID, needed for `defs` documents that have no `main`.
  They can hold identical strings, so a wrong one type-checks and only fails
  at runtime.
- **`$matches` vs `$isTypeOf`.** `$isTypeOf` only reads the `$type` tag;
  `$matches` validates the whole value. Substituting `$isTypeOf` where the
  data is unvalidated silently accepts malformed records. See
  [references/types-and-guards.md](references/types-and-guards.md).
- **`encoding` widening** affects only the bare-handler form of `server.add`,
  not the object form. Don't sprinkle `as const` everywhere — see
  [references/routes.md](references/routes.md).
- **Branded strings arriving from outside TypeScript's view** — protobuf,
  Kysely rows, `JSON.parse` — are plain `string`. Cast once at the boundary
  (`as DidString`), never deep in the call graph.
- **Generated `src/lexicons/` is gitignored.** It won't exist until
  `pnpm run codegen` (or a build) runs. "Cannot find module '../lexicons/index.js'"
  means codegen hasn't run, not that the import is wrong.

## Tests

Test _call sites_ keep using `AtpAgent`, deliberately (see principles above).
`dev-env` exposes both `getAgent(): AtpAgent` and `getClient(): Client`, so a
new-style client is available when a test genuinely needs one.

What must move is anything a test imported from the now-deleted
`src/lexicon/`. Two valid destinations, both already used in migrated
packages:

```diff
- import { ids } from '../../src/lexicon/lexicons'
- import { isRepoRef } from '../../src/lexicon/types/com/atproto/admin/defs'
+ import { com } from '../../src/lexicons/index.js'      // generated schemas
+ import { $Typed, ComAtprotoAdminDefs, ids } from '@atproto/api'  // legacy equivalents
```

Prefer `../../src/lexicons/index.js` for new code; `@atproto/api` is the
lower-churn option when the surrounding test is otherwise untouched. Do not
add `@atproto/api` back to a package's dependencies just for this — it is a
devDependency in `pds` and only a runtime dependency in `bsky` because of
`getAgeAssuranceRegionConfig`, unrelated to tests.

Before writing or restructuring tests, use the
[testing skill](../testing/SKILL.md) — `pds` and `ozone` are jest, `bsky` is
vitest.

## Related skills

[xrpc-server](../xrpc-server/SKILL.md) documents the target route API in full
and is the better reference once a package is migrated.
[lex-setup](../lex-setup/SKILL.md) owns codegen wiring,
[lex-schema](../lex-schema/SKILL.md) the `$`-accessors, and
[lex-data](../lex-data/SKILL.md) branded strings, CIDs, and blobs.

