···4040- **Template** — a stored element tree with Binding placeholders that get filled in with props.
4141- **External** — an XRPC endpoint that receives props and returns an element tree.
42424343-Each component also imports a list of [packs](https://pdsls.dev/at://did:plc:mdg3w2kpadcyxy33pizokzf3/com.atproto.lexicon.schema/at.inlay.pack#schema). A pack maps NSIDs to component URIs — it's how the host knows which implementation to use.
4343+Each component also imports an ordered list of DIDs. When the host needs to resolve a child NSID, it walks the import list and looks up `at://{did}/at.inlay.component/{nsid}` — first match wins.
44444545[Atsui (`org.atsui`)](https://pdsls.dev/at://did:plc:e4fjueijznwqm2yxvt7q4mba/com.atproto.lexicon.schema) is the first design system for Inlay — including `<Text>`, `<Avatar>`, `<List>`, and others. In a sense, it's like Inlay's HTML. Atsui is not a traditional component library because it solely defines *lexicons* (i.e. interfaces). Each host (an Inlay browser like [this demo](https://inlay-proto.up.railway.app/)) may choose which components are built-in, and how they work. A host could even decide to not implement Atsui, and instead to implement another set of primitives.
4646···52525353### Pick an NSID
54545555-An NSID describes *what* a component does, not *how*. If someone already defined one that fits (e.g. `com.pfrazee.BskyPost` with a `uri` prop), you can implement it yourself. Multiple implementations of the same NSID can coexist — packs control which one gets used.
5555+An NSID describes *what* a component does, not *how*. If someone already defined one that fits (e.g. `com.pfrazee.BskyPost` with a `uri` prop), you can implement it yourself. Multiple implementations of the same NSID can coexist — the importer's DID list controls which one gets used.
56565757If you're creating a new NSID, pick one under a domain you control so you can [publish a Lexicon](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution) for it later.
5858···6767```json
6868{
6969 "$type": "at.inlay.component",
7070- "type": "mov.danabra.Greeting",
7170 "body": {
7271 "$type": "at.inlay.component#bodyTemplate",
7372 "node": {
···9190 }
9291 },
9392 "imports": [
9494- "at://did:plc:mdg3w2kpadcyxy33pizokzf3/at.inlay.pack/3me7vlpfkcj25",
9595- "at://did:plc:e4fjueijznwqm2yxvt7q4mba/at.inlay.pack/3me3pkxzcf22m"
9393+ "did:plc:mdg3w2kpadcyxy33pizokzf3",
9494+ "did:plc:e4fjueijznwqm2yxvt7q4mba"
9695 ]
9796}
9897```
9998100100-The `imports` array lists the packs your component needs — typically the [Atsui pack](https://pdsls.dev/at/did:plc:e4fjueijznwqm2yxvt7q4mba/at.inlay.pack/3me3pkxzcf22m) so you can use [`org.atsui.*` primitives](https://pdsls.dev/at://did:plc:e4fjueijznwqm2yxvt7q4mba/com.atproto.lexicon.schema).
9999+The `imports` array lists the DIDs your component needs for resolution — typically the [Atsui DID](https://pdsls.dev/at/did:plc:e4fjueijznwqm2yxvt7q4mba) so you can use [`org.atsui.*` primitives](https://pdsls.dev/at://did:plc:e4fjueijznwqm2yxvt7q4mba/com.atproto.lexicon.schema). The record's rkey is the NSID it implements (e.g. `mov.danabra.Greeting`), so the full URI is `at://{your-did}/at.inlay.component/mov.danabra.Greeting`.
101100102101When [rendered](packages/@inlay/render) as `<mov.danabra.Greeting name="world" />`, this component resolves to:
103102···112111113112As an alternative to templates (which are very limited), you can declare components as [XRPC](https://atproto.com/guides/glossary#xrpc) server endpoints. Then the Inlay host will hit your endpoint to resolve the component tree.
114113115115-Here's a [real external component record](https://pdsls.dev/at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/at.inlay.component/3mfoxe7h4fsj6) for `mov.danabra.Greeting`. Notice its `body` points to a service `did` instead of an inline `node`:
114114+Here's a [real external component record](https://pdsls.dev/at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/at.inlay.component/mov.danabra.Greeting) for `mov.danabra.Greeting`. Notice its `body` points to a service `did` instead of an inline `node`:
116115117116```json
118117{
119118 "$type": "at.inlay.component",
120120- "type": "mov.danabra.Greeting",
121119 "body": {
122120 "$type": "at.inlay.component#bodyExternal",
123121 "did": "did:web:gaearon--019c954e8a7877ea8d8d19feb1d4bbbb.web.val.run"
124122 },
125123 "imports": [
126126- "at://did:plc:mdg3w2kpadcyxy33pizokzf3/at.inlay.pack/3me7vlpfkcj25",
127127- "at://did:plc:e4fjueijznwqm2yxvt7q4mba/at.inlay.pack/3me3pkxzcf22m"
124124+ "did:plc:mdg3w2kpadcyxy33pizokzf3",
125125+ "did:plc:e4fjueijznwqm2yxvt7q4mba"
128126 ]
129127}
130128```
···150148151149It's recommended to tag XRPC return values as cacheable; see [`@inlay/cache`](packages/@inlay/cache) for how to do this. In the calling code, it's recommended to wrap XRPC components into `<at.inlay.Loading fallback=...>` so that they don't block the entire page.
152150153153-### Pack record
154154-155155-To let other components use yours, add it to a pack. Here's the [`danabra.mov/ui` pack](https://pdsls.dev/at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/at.inlay.pack/3meflbzhr7c2f):
156156-157157-```json
158158-{
159159- "$type": "at.inlay.pack",
160160- "name": "ui",
161161- "exports": [
162162- { "type": "mov.danabra.Greeting", "component": "at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/at.inlay.component/3mfoxe7h4fsj6" },
163163- ...
164164- ]
165165-}
166166-```
167167-168168-Other components import this pack URI in their `imports` array to get access to `mov.danabra.Greeting`.
169169-170151### Lexicon (optional)
171152172153A [Lexicon](https://atproto.com/specs/lexicon) defines the prop schema for an NSID. You don't need one to get started, but publishing one enables type checking, validation, and codegen with [`@atproto/lex`](https://www.npmjs.com/package/@atproto/lex):
···201182202183## Rendering as a host
203184204204-A host walks an element tree, resolves each component through its packs, and maps the resulting primitives to output (HTML, React, etc).
185185+A host walks an element tree, resolves each component through its import stack (DIDs), and maps the resulting primitives to output (HTML, React, etc).
205186206187[`@inlay/render`](packages/@inlay/render) does the resolution step — call `render()` on an element, get back the expanded tree or a primitive. The host calls it in a loop until everything is resolved. The [render README](packages/@inlay/render) has a minimal working example that turns Inlay elements into HTML.
207188
-1
generated/at/inlay.ts
···33 */
4455export * as Binding from "./inlay/Binding";
66-export * as pack from "./inlay/pack";
76export * as Fragment from "./inlay/Fragment";
87export * as Loading from "./inlay/Loading";
98export * as Maybe from "./inlay/Maybe";
+7-13
generated/at/inlay/component.defs.ts
···991010export { $nsid };
11111212-/** Component record - declares an implementation of a type */
1212+/** Component record. The rkey is the type NSID this component implements. */
1313type Main = {
1414 $type: "at.inlay.component";
1515-1616- /**
1717- * NSID this component implements (also the XRPC procedure)
1818- */
1919- type: l.NsidString;
20152116 /**
2217 * How this component is rendered. Omit for primitives rendered by the host.
···3227 view?: View;
33283429 /**
3535- * Ordered list of pack URIs (import stack). First pack that exports an NSID wins.
3030+ * Ordered list of DIDs (import stack). For each NSID, the first DID that has a component with that rkey wins.
3631 */
3737- imports?: l.AtUriString[];
3232+ imports?: l.DidString[];
38333934 /**
4035 * Platform-managed deployment metadata
···51465247export type { Main };
53485454-/** Component record - declares an implementation of a type */
5555-const main = l.record<"tid", Main>(
5656- "tid",
4949+/** Component record. The rkey is the type NSID this component implements. */
5050+const main = l.record<"nsid", Main>(
5151+ "nsid",
5752 $nsid,
5853 l.object({
5959- type: l.string({ format: "nsid" }),
6054 body: l.optional(
6155 l.typedUnion(
6256 [
···6761 )
6862 ),
6963 view: l.optional(l.ref<View>((() => view) as any)),
7070- imports: l.optional(l.array(l.string({ format: "at-uri" }))),
6464+ imports: l.optional(l.array(l.string({ format: "did" }))),
7165 via: l.optional(
7266 l.typedUnion(
7367 [l.typedRef<InlayDefs.ViaValtown>((() => InlayDefs.viaValtown) as any)],
-80
generated/at/inlay/pack.defs.ts
···11-/*
22- * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
33- */
44-55-import { l } from "@atproto/lex";
66-77-const $nsid = "at.inlay.pack";
88-99-export { $nsid };
1010-1111-/** A list of type to component exports */
1212-type Main = {
1313- $type: "at.inlay.pack";
1414-1515- /**
1616- * Short slug for the pack (e.g. "core", "ui")
1717- */
1818- name: string;
1919-2020- /**
2121- * Type to component mappings
2222- */
2323- exports: Export$0[];
2424- createdAt?: l.DatetimeString;
2525-};
2626-2727-export type { Main };
2828-2929-/** A list of type to component exports */
3030-const main = l.record<"tid", Main>(
3131- "tid",
3232- $nsid,
3333- l.object({
3434- name: l.string({ maxLength: 64 }),
3535- exports: l.array(l.ref<Export$0>((() => export$0) as any)),
3636- createdAt: l.optional(l.string({ format: "datetime" })),
3737- })
3838-);
3939-4040-export { main };
4141-4242-export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main),
4343- $build = /*#__PURE__*/ main.build.bind(main),
4444- $type = /*#__PURE__*/ main.$type;
4545-export const $assert = /*#__PURE__*/ main.assert.bind(main),
4646- $check = /*#__PURE__*/ main.check.bind(main),
4747- $cast = /*#__PURE__*/ main.cast.bind(main),
4848- $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main),
4949- $matches = /*#__PURE__*/ main.matches.bind(main),
5050- $parse = /*#__PURE__*/ main.parse.bind(main),
5151- $safeParse = /*#__PURE__*/ main.safeParse.bind(main),
5252- $validate = /*#__PURE__*/ main.validate.bind(main),
5353- $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main);
5454-5555-type Export$0 = {
5656- $type?: "at.inlay.pack#export";
5757-5858- /**
5959- * NSID of the type being exported
6060- */
6161- type: l.NsidString;
6262-6363- /**
6464- * AT-URI of the component record
6565- */
6666- component: l.AtUriString;
6767-};
6868-6969-export type { Export$0 as Export };
7070-7171-const export$0 = l.typedObject<Export$0>(
7272- $nsid,
7373- "export",
7474- l.object({
7575- type: l.string({ format: "nsid" }),
7676- component: l.string({ format: "at-uri" }),
7777- })
7878-);
7979-8080-export { export$0 as "export" };
-6
generated/at/inlay/pack.ts
···11-/*
22- * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
33- */
44-55-export * from "./pack.defs";
66-export * as $defs from "./pack.defs";
+5-10
lexicons/at/inlay/component.json
···44 "defs": {
55 "main": {
66 "type": "record",
77- "key": "tid",
88- "description": "Component record - declares an implementation of a type",
77+ "key": "nsid",
88+ "description": "Component record. The rkey is the type NSID this component implements.",
99 "record": {
1010 "type": "object",
1111- "required": ["type"],
1111+ "required": [],
1212 "properties": {
1313- "type": {
1414- "type": "string",
1515- "format": "nsid",
1616- "description": "NSID this component implements (also the XRPC procedure)"
1717- },
1813 "body": {
1914 "type": "union",
2015 "refs": ["#bodyExternal", "#bodyTemplate"],
···2924 "type": "array",
3025 "items": {
3126 "type": "string",
3232- "format": "at-uri"
2727+ "format": "did"
3328 },
3434- "description": "Ordered list of pack URIs (import stack). First pack that exports an NSID wins."
2929+ "description": "Ordered list of DIDs (import stack). For each NSID, the first DID that has a component with that rkey wins."
3530 },
3631 "via": {
3732 "type": "union",
···10101111## Example
12121313-A minimal Hono server that accepts an element, resolves it through packs, and returns HTML. Uses Hono's JSX for safe output.
1313+A minimal Hono server that accepts an element, resolves it, and returns HTML. Uses Hono's JSX for safe output.
14141515```tsx
1616/** @jsxImportSource hono/jsx */
···2020import { render } from "@inlay/render";
2121import type { RenderContext } from "@inlay/render";
22222323-// Records — in-memory stand-in for AT Protocol
2323+// Records — in-memory stand-in for AT Protocol.
2424+// Each component's rkey is the NSID it implements.
2425const records = {
2526 // Primitives: no body. The host renders these directly.
2626- "at://did:plc:host/at.inlay.component/stack": {
2727- $type: "at.inlay.component", type: "org.atsui.Stack",
2727+ "at://did:plc:host/at.inlay.component/org.atsui.Stack": {
2828+ $type: "at.inlay.component",
2829 },
2929- "at://did:plc:host/at.inlay.component/text": {
3030- $type: "at.inlay.component", type: "org.atsui.Text",
3030+ "at://did:plc:host/at.inlay.component/org.atsui.Text": {
3131+ $type: "at.inlay.component",
3132 },
32333334 // Template: a stored element tree with Binding placeholders.
3434- "at://did:plc:app/at.inlay.component/greeting": {
3535+ "at://did:plc:app/at.inlay.component/com.example.Greeting": {
3536 $type: "at.inlay.component",
3636- type: "com.example.Greeting",
3737 body: {
3838 $type: "at.inlay.component#bodyTemplate",
3939 node: serializeTree(
···4343 )
4444 ),
4545 },
4646- imports: ["at://did:plc:host/at.inlay.pack/main"],
4747- },
4848-4949- // Pack: maps NSIDs → component records
5050- "at://did:plc:host/at.inlay.pack/main": {
5151- $type: "at.inlay.pack", name: "host",
5252- exports: [
5353- { type: "org.atsui.Stack", component: "at://did:plc:host/at.inlay.component/stack" },
5454- { type: "org.atsui.Text", component: "at://did:plc:host/at.inlay.component/text" },
5555- { type: "com.example.Greeting", component: "at://did:plc:app/at.inlay.component/greeting" },
5656- ],
4646+ imports: ["did:plc:host"],
5747 },
5848};
5949···112102 const element = deserializeTree({
113103 $: "$", type: "com.example.Greeting", props: { name: "world" },
114104 });
115115- const ctx = { imports: ["at://did:plc:host/at.inlay.pack/main"] };
105105+ const ctx = { imports: ["did:plc:host", "did:plc:app"] };
116106 const body = await renderNode(element, ctx);
117107 return c.html(<html><body>{body}</body></html>);
118108});
···148138`render()` resolves one element at a time. It returns `node: null` when the element is a primitive (the host renders it using `element.type` + `result.props`) or a non-null node tree when the element expanded into a subtree that needs more passes. The walk loop drives this to completion.
1491391501401. **Binding resolution** — `at.inlay.Binding` elements in props are resolved against the current scope. The concrete values are available on `result.props`.
151151-2. **Type lookup** — the element's NSID is looked up across the import stack. First pack that exports the type wins.
141141+2. **Type lookup** — the element's NSID is looked up across the import stack. For each DID, the renderer checks `at://{did}/at.inlay.component/{nsid}` — first match wins.
1521423. **Component rendering** — depends on the component's body:
153143 - **No body** — a primitive; returns `node: null` with resolved props.
154144 - **Template** — element tree expanded with prop bindings.
+16-40
packages/@inlay/render/src/index.ts
···1313 AtUri,
1414 AtUriString,
1515 AtIdentifierString,
1616+ DidString,
1617 NsidString,
1718 ensureValidNsid,
1819 ensureValidDid,
···2425 View,
2526} from "../../../../generated/at/inlay/component.defs.js";
2627import { viewRecord as viewRecordSchema } from "../../../../generated/at/inlay/component.defs.js";
2727-import type { Main as PackRecord } from "../../../../generated/at/inlay/pack.defs.js";
2828import type { CachePolicy } from "../../../../generated/at/inlay/defs.defs.js";
29293030// --- Public types ---
···5454/**
5555 * Plain, serializable render context.
5656 *
5757- * - `imports`: ordered pack URIs for type resolution
5757+ * - `imports`: ordered DIDs for type resolution
5858 * - `component`: when present, the first render uses this component directly
5959 * (root render). Stripped from child contexts automatically.
6060 * - `componentUri`: AT URI of the root component record. Passed to xrpc
···6767 * component first — the "who rendered who" owner chain.
6868 */
6969export type RenderContext = {
7070- imports: AtUriString[];
7070+ imports: DidString[];
7171 component?: ComponentRecord;
7272 componentUri?: string;
7373 depth?: number;
···104104105105export function createContext(
106106 component: ComponentRecord,
107107- componentUri?: string
107107+ componentUri: string
108108): RenderContext {
109109 return {
110110 imports: component.imports ?? [],
···136136137137 // Root render: use component directly
138138 if (ctx.component) {
139139- if (type !== ctx.component.type) {
140140- throw new Error(
141141- `render was given ${ctx.component.type}, cannot render ${type}`
142142- );
139139+ const nsid = new AtUri(ctx.componentUri!).rkey;
140140+ if (type !== nsid) {
141141+ throw new Error(`render was given ${nsid}, cannot render ${type}`);
143142 }
144143 errorStack = [type, ...(ctx.stack ?? [])];
145144 return await renderComponent(
···325324326325async function resolveType(
327326 nsid: string,
328328- importStack: AtUriString[],
327327+ importStack: DidString[],
329328 resolver: Resolver
330329): Promise<{
331330 componentUri: string;
332332- packUri: string;
333331 component: ComponentRecord;
334332}> {
335335- const packPromises = importStack.map((uri) => resolver.fetchRecord(uri));
333333+ const uris = importStack.map(
334334+ (did) => `at://${did}/at.inlay.component/${nsid}` as AtUriString
335335+ );
336336+ const promises = uris.map((uri) => resolver.fetchRecord(uri));
336337337338 for (let i = 0; i < importStack.length; i++) {
338338- const pack = (await packPromises[i]) as PackRecord | null;
339339- if (!pack || !pack.exports) {
340340- continue;
341341- }
342342-343343- const entry = pack.exports.find((e) => e.type === nsid);
344344- if (!entry) {
345345- continue;
346346- }
347347-348348- const component = (await resolver.fetchRecord(
349349- entry.component
350350- )) as ComponentRecord | null;
351351- if (!component) {
352352- continue;
353353- }
354354-355355- return {
356356- componentUri: entry.component,
357357- packUri: importStack[i],
358358- component,
359359- };
339339+ const component = (await promises[i]) as ComponentRecord | null;
340340+ if (!component) continue;
341341+ return { componentUri: uris[i], component };
360342 }
361343362362- throw new Error(`No pack exports type: ${nsid}`);
344344+ throw new Error(`Unresolved type: ${nsid}`);
363345}
364346365347async function renderTemplate(
···373355 const depth = ctx.depth ?? 0;
374356 // Push this component onto the owner stack — it creates child elements.
375357 const stack = [type, ...(ctx.stack ?? [])];
376376-377377- // Eagerly prefetch child packs so they overlap with record fetch
378378- const childImports = component.imports ?? [];
379379- for (const uri of childImports) {
380380- resolver.fetchRecord(uri).catch(() => {});
381381- }
382358383359 const tree = deserializeTree(body.node);
384360