Skip to content
Plugins

Admin extensions

Add screens, sidebar items, editor panels, field types, Dashboard widgets and commands to the admin, with the same tools Lettern uses.

24 min readView as Markdown
On this page

Your plugin can add to the admin: a screen of its own, a tab in the entry editor, a field type, a widget on the Dashboard, a command in ⌘K. Screens are data first: a list, a form, tabs and buttons bound to your abilities are a view, drawn by Lettern with no React of yours. What a view can't draw, such as an editor, a panel or a field type, you write in React with the same SDK and components Lettern's own screens are made of, so it looks and behaves like the rest of the admin, in light and dark.

#Add a screen

Start with a view. For a screen that is itself an editor, there are two parts: PHP declares the sidebar item, and your admin module draws the screen.

PHP
// In your provider's boot()
$this->cms()->admin()->nav('reviews', ['path' => '/reviews', 'icon' => 'star', 'section' => 'content']);
$this->adminModule();
TypeScript
// admin/src/index.tsx
import { registry, t, useAbility } from '@letterncms/sdk'
import { Page } from '@letterncms/ui'

function ReviewsScreen() {
  const reviews = useAbility<{ id: string; title: string }[]>('reviews/list', {})

  return (
    <Page crumbs={[{ label: t('Reviews'), icon: 'star' }]}>
      {reviews.data?.map((review) => <p key={review.id}>{review.title}</p>)}
    </Page>
  )
}

registry.routes.set('/reviews', { path: '/reviews', component: ReviewsScreen })

Build it to dist/admin.js (see Build setup), and Reviews appears under Content in the sidebar. Without a label, a plugin's main screen takes the plugin's name, extra.cms.label in its composer.json, so the screen, its settings and its row in Plugins are all called the same. useAbility runs one of your abilities with the signed-in person's permissions, so the screen never sees more than they may.

#What you can import

Your admin code is an ES module, built with Vite to dist/admin.js and loaded with $this->adminModule(). It imports from shared packages the admin provides at runtime, so your bundle holds only your own code:

PackageHolds
reactReact 19: one copy for everyone.
@letterncms/sdkTalking to Lettern: t and plural (translations), brand (the product's name, links and images: never write the name, and in a translated string use the {brand} placeholder, as in t('Ask {brand}')), run, request (and requestText, for an answer that is text, such as the site's rendering of a page), eventStream, useAbility, useAction, invalidate, the router (navigate, Link, useLocation, useTab: tabs kept in the URL as ?tab=, so a refresh opens the same one, and confirmLeaving(), which the admin uses to ask about unsaved changes in its own dialog), can, useManifest, setPreference (one of the signed-in person's preferences: this session's copy changes at once, their account follows), useScreenContext, registry (with registry.composer for @ and / in a thread) and events.
@letterncms/uiThe admin's primitives: Button, IconButton, Input, Textarea, Select, Switch, Checkbox, Segmented and Tabs (both take fit: one line that shrinks to the room there is; a tab can have an icon), Field, Fields, Page, Section, ScreenTabs, ScreenSidebar, SaveBar, List, Dialog, confirm, prompt, Popover, Menu, SidebarRow, Tooltip, Badge, Notice, QueryError, Avatar, PersonPicker, Spinner, EmptyState, SortableTree, BlueprintEditor, FieldsEditor, Icon and toast, plus copyText (the clipboard, saying how it went), formatBytes (a file size in the admin's language), deviceWidths (the tablet and phone widths previews use), useShortcut, slugify and the color scheme.
@dnd-kit/*Drag and drop (core, sortable, utilities), shared so no module bundles its own.

The admin's own modules are built with exactly these, nothing more.

#Build setup

A module builds with three npm packages, as ordinary devDependencies: @letterncms/admin (the Vite setup and theme.css), @letterncms/sdk and @letterncms/ui (types; the admin provides the code at runtime). Your first plugin has every file.

JavaScript
// vite.config.mjs
import { defineConfig } from 'vite'
import { cmsAdminModule } from '@letterncms/admin/vite'

export default defineConfig(cmsAdminModule({ entry: 'admin/src/index.tsx' }))

For CSS, import Tailwind's utilities into the plugins layer. The shared setup moves your variant utilities (md:, hover:, dark:…) into the admin's variants layer at build time, so they win over plain utilities whichever stylesheet those come from:

CSS
@import "tailwindcss/theme" layer(theme);
@import "tailwindcss/utilities" layer(plugins);
@import "@letterncms/admin/theme.css";

adminModule() loads dist/admin.css too when it's there. A plugin with no stylesheet of its own passes css: null.

#Register things

The PHP side declares what the admin lists: sections and sidebar items, Dashboard widgets and commands. The JS side registers the components behind them.

PHP
$admin = $this->cms()->admin();
$admin->section('shop', 'Shop', order: 30);
$admin->nav('reviews', ['label' => 'Reviews', 'path' => '/reviews', 'icon' => 'star', 'section' => 'shop', 'permission' => 'reviews.manage']);
$admin->widget('reviews-latest', ['label' => 'Latest reviews', 'description' => 'New reviews waiting for a reply.', 'icon' => 'star', 'column' => 'main', 'permission' => 'reviews.manage']);
$admin->command('reviews-new', ['label' => 'Moderate reviews', 'path' => '/reviews', 'keywords' => ['ratings']]);
TypeScript
import { registry } from '@letterncms/sdk'
import { lazy } from 'react'

registry.routes.set('/reviews', { path: '/reviews', component: lazy(() => import('./ReviewsScreen')) })
registry.widgets.set('reviews-latest', lazy(() => import('./WidgetLatest')))
registry.fields.set('rating', RatingField)
registry.commands.set('reviews-ask', { label: 'Summarize reviews', perform: () => { /* … */ } })
RegistryFor
routesScreens. overlay: true draws one over the screen it was opened from, which stays open underneath: go there and back with navigate(path, { overlay: true }), which doesn't ask about unsaved changes. private: true keeps a screen's title and text out of useScreenContext readers.
fieldsThe editing component for a field type.
panelsComponents in named slots.
widgetsDashboard widgets (declared in PHP, rendered from here).
componentsComponents the assistant can compose: see Generative UI.
commandsCommand palette entries, also offered under / in a thread. acceptsQuery: true offers the command for any typed text.
entryActionsLines in the entry editor's More Actions menu (⋯).
composerWhat @ and / offer in a thread: see In the workspace.
iconsExtra icons by name.

A later registration under the same key wins, and when its module goes away, what it replaced comes back. A panel, command, entry action or composer source is known by its key: read back from the registry, it has that key as its id.

A command declared in PHP takes label, icon, group, path or ability (with input), keywords and permission.

#Load data before a screen opens

A screen can prepare its data before navigation commits. Add a load(params, location) function to the route, and use prefetchAbility with the same ability and input as the screen's useAbility call:

TypeScript
import { prefetchAbility, registry } from '@letterncms/sdk'

registry.routes.set('/reviews/:id', {
  path: '/reviews/:id',
  component: lazy(() => import('./ReviewEditor')),
  load: ({ id }) => prefetchAbility('reviews/get', { id }),
})

The admin starts loading code and data on link hover or focus. While an uncached destination loads, the current screen stays visible with the shell's progress indicator; cached results open immediately and stale data refreshes in the background.

  • location.query holds the destination's query parameters.
  • Prepare independent queries with Promise.all.
  • Loaders must only read data: they can run before a link is followed.
  • A later navigation takes precedence over an earlier request.
  • Fetch errors still open the destination, so its error and retry UI can handle them.

#The sidebar

The sidebar has two states, both drawn from registered sections and items. The screen on view decides which one shows:

  • The main sidebar, where people work: every item, section by section, in the order each person put them. The kernel registers the sections main (no heading: Dashboard, Analytics, Plugins), content (Content) and design (Design). Each collection is an item too (collection.<handle>, in the section its definition names), so Templates and Components sit under Design. Everything is there from the start; More appears only for what someone unpins.
  • The settings sidebar, opened with the gear beside your name or ⇧⌘,: a way back, then what is set up once. The kernel registers account, site and system, shown together without a heading (Account, General, Languages, Media, Email, Audit Log), then people (People: Users, Roles) and plugins (Plugins). Each settings page is an item (settings.<namespace>). Whatever a plugin puts in a settings section is listed under Plugins, by the plugin's name, so no plugin makes a group of its own; that includes settings kept as a Settings tab on the plugin's own screen (see Settings). Settings are a place to visit, not something to arrange: these items are never pinned.
PHP
$admin->section('shop', 'Shop', order: 30);                       // a section of the main sidebar
$admin->section('billing', 'Billing', order: 15, settings: true); // a section of the settings sidebar
Item keyMeaning
label, path, iconWhat the row shows and where it goes. Leave out label on a plugin's main screen to use the plugin's name.
sectionThe section it's listed in: of the main sidebar, or of the settings sidebar when the section is registered with settings: true.
orderIts default place in the section.
pinnedtrue by default: in everyone's sidebar until they arrange their own. false puts it under More, for a screen nearly nobody opens.
shortcutA key that opens it, shown with the label (e.g. mod+j).
permissionWho may open it. visible (a fn (User $user): bool) decides per person beyond that.
badgeA callable returning a count (new form submissions), shown with the label.

#How people arrange it

Every row in the sidebar (pinned, under More, settings) and every SidebarRow a plugin draws behaves the same:

  • A label too long for the row slides to show its end on hover.
  • The row's main action (Unpin, Pin to Sidebar, Delete Chat) shows as a button on hover. Tab moves from row to row without stopping on it.
  • Right-click (long-press on touch) lists everything the row can do, starting with Open in New Tab.

Everyone arranges their own sidebar: Unpin, Move Up and Move Down on a row, Pin to Sidebar under More, drag to reorder, or Customize Sidebar… (in every row's menu, at the end of More and in ⌘K) for every item at once. Their arrangement is the sidebar preference, and Reset to Default in Customize Sidebar gives it up. Until they make one, they see the site's default: Save as Default writes it to the sidebar value of the admin settings, and Reset Everyone's Default puts back what modules and plugins registered.

A new item lands in its section after what's there, pinned unless registered pinned: false. An item that disappears (a plugin turned off) keeps its place for when it returns. Items under More stay in ⌘K: pinning is about focus, and the item's permission decides access.

A module puts its own item away or back with events.emit('sidebar.pin', { id, pinned }) (unpinning offers Undo); events.emit('sidebar.customize') opens Customize Sidebar.

#Dashboard widgets

Every plugin registers at least one widget. The Dashboard is what people see first, so each plugin brings something worth a glance there: new submissions, the health of a connection, what changed.

A plugin without a widget keeps working but is reported as invalid: Plugins shows the problem, cms plugins lists it (and exits with an error, so CI catches it) and, in debug mode, the Dashboard names it. The admin's own modules are exempt: they are the admin itself.

PHP
$admin->widget('reviews-latest', ['label' => 'Latest reviews', 'description' => 'New reviews waiting for a reply.', 'icon' => 'star', 'column' => 'main']);
KeyMeaning
label, iconThe widget's title, and its card in the gallery.
descriptionOne short sentence for the gallery: what it shows.
columnWhere it lands when added: main (wide) or side (narrow, the default).
enabledOn everyone's Dashboard until they rearrange it. Leave it off: only Recently Edited, Site and Analytics start on, so nobody is overwhelmed. People add the rest from the gallery.
permissionWho may see and add it.
orderIts place among the others in a column.
assistantfalse keeps it out of what an agent answers with: see Generative UI.

#Draw it

Draw the widget with Widget from @letterncms/ui. It takes the registered label for its title and handles loading, errors and the empty state, so every widget looks and behaves the same:

TypeScript
import { t, timeAgo, useAbility } from '@letterncms/sdk'
import { Widget, WidgetRow } from '@letterncms/ui'

export default function WidgetLatest() {
  const reviews = useAbility<Review[]>('reviews/latest', { limit: 5 })

  return (
    <Widget action={{ label: t('All reviews'), to: '/reviews' }} query={reviews} empty={reviews.data?.length === 0 && { icon: 'star', title: t('No reviews yet') }}>
      {reviews.data?.map((r) => <WidgetRow key={r.id} to={`/reviews/${r.id}`} icon="star" title={r.title} meta={timeAgo(r.createdAt)} />)}
    </Widget>
  )
}

WidgetRow, Stat, Delta, Sparkline, Meter, BarRow and compactNumber are the parts the built-in widgets are made of. The body is a size container: adapt to the column with container variants such as @md:grid-cols-4. Give a widget the data it needs through a read-only ability, so agents can use it too.

#How people arrange it

People customize their Dashboard on the Dashboard itself. Customize rings each widget where it is: drag widgets within and between the columns, remove them, and add more with each column's Add Widget, from the gallery that shows every widget with its description and the plugin it comes from.

The result is their own (the dashboard value of their preferences), and Reset to Default gives it up. Until they make one, they see the site's default, which admins set with Save as Default while customizing: it's the dashboard value of the admin settings, and the admin/arrangement ability returns it for agents. Reset Everyone's Default puts back what modules and plugins registered.

#Slots

A slot is a named place in an existing screen where your component appears, like a tab in the entry editor's side panel:

TypeScript
registry.panels.set('reviews-summary', { slot: 'entry.sidebar', label: 'Reviews', icon: 'star', component: ReviewsPanel, when: (p) => p.collection.handle === 'products' })

when decides where it applies, and order its place among others. The slot decides the props: PanelSlots from @letterncms/sdk maps each slot to them, so when and the component are typed by the slot you name.

SlotWhereProps
entry.toolbarEntry editor toolbarEntryPanelProps: entry, collection, content, values, title, setValue, save, reload, editable, can, errors
entry.sidebarTabs in the entry side panel (give label and icon)Same
entry.bodyExtra content after the main body, supplied through the workspace footerSame
entry.editorThe shared body workspace for the explicitly configured blocks fieldEntryEditorProps
shell.dockA column at the right edge, on every screen. Draw it with Dock from @letterncms/ui (open, full, floating, label): the page stays usable next to it, people can drag it wider, floating makes it a card over the page that moves by an element with data-dock-drag, full grows it over the screen, and small windows get it over the page. The workspace's thread is one
sidebar.startRows at the top of the main sidebar, above what's pinned: the workspace's New Chat. Draw them with SidebarRowonNavigate
workspace.composerIn the thread's composer, beside Send: an agent's own controls, such as the assistant's model pickerComposerPanelProps: thread, agent (the one answering there, its name and label, or null), state, options, setOption, busy, loading
shell.bannerA strip above the screen, beside the sidebar, for a notice that concerns the whole admin (the license and update notices)
topbar.endIn every screen's top bar, before the screen's own actions: the way back to what's docked
account.apiPart of API & Agents on Account, before the tokens: the MCP module shows its server URL and the Claude Code command here

Account and Plugins are views: add a section to Account, or a tab to Plugins after Installed, Store and Updates (as the license module adds Licenses), with the view.account and view.plugins filters.

#Entry panels

EntryPanelProps is exported by @letterncms/sdk. entry is null until the first save.

  • content: EntryContent is the complete current working snapshot of title, slug, data and authorId. values and title expose its data and title separately.
  • editable says whether to allow editing, can holds the collection's create, edit, publish and delete capabilities, and errors the field validation messages.

Panels must respect these capabilities rather than assume everyone can edit or restore a version.

A sidebar panel that edits blueprint fields declares their handles with fieldHandles:

TypeScript
registry.panels.set('product-reviews', {
  slot: 'entry.sidebar', label: t('Reviews'),
  component: ReviewsPanel, fieldHandles: ['reviews'],
  when: (p) => p.collection.handle === 'products',
})

The entry host routes claimed fields to active panels. Unclaimed fields stay in the normal entry UI, including fields from custom sections or a disabled plugin; a field's section name alone doesn't hide it. Declare only the handles the panel actually renders, and show their validation errors there. The shared sidebar always starts with the collection's singular-name details tab, then History, then plugin panels.

#Entry actions and preview

registry.entryActions.set(id, { label, icon, order, when, perform }) adds a line to the entry editor's More Actions menu (⋯).

Site owns the single Preview dialog, opened from that menu. It previews the complete unsaved snapshot, including before the first save, with play: true through the site renderer. A live entry with nothing unpublished offers View on Site there instead. To open it from your code, use events.emit('entry.preview') from @letterncms/sdk instead of adding another preview control or rendering store.

#Screen layout

A screen has two side panels at most, one on each side:

  • On the left, ScreenSidebar from @letterncms/ui (title, backTo, backLabel) brings a state of the screen's own to the sidebar, in the same frame as the settings one. It's out or folded away as each person last left it (folded the first time), brought out by the same button and ⌘\, with its way back beside that button, in or out. It takes the sidebar over at once, without sliding, and a screen can claim it while what fills it still loads. The block workspace's Outline does this. With nested, the list is a screen's sections instead, set in under the screen's row while the sidebar stays as it is, as the Design System's are; only when that row is under More do they take the sidebar over. useScreenSidebar() gives its state, nested included.
  • On the right is InspectorPanel. A screen that uses it says so with useScreenContext({ rightEdge: true }), and a dock floats there instead.

Every screen has one compact top bar: its name after the places that lead to it (crumbs) on the left, what it can do on the right, ending with its main action. An editor's own tools go there through the toolbar element in EntryEditorProps, and keep to a few. A screen about one thing gives Topbar a back and a title instead of crumbs. With a ScreenSidebar, the sidebar's way back stands in for back.

#Tabs and saving

A screen with tabs of its own uses ScreenTabs and useScreenTabs() from @letterncms/ui. When the plugin keeps its settings with the screen, they follow as its last tab, Settings: see Settings.

A form is saved with SaveBar (dirty, saving, error, onSave, onDiscard): a bar over the bottom of the screen, there only while something changed, with Discard and Save (⌘S), that says what went wrong when saving fails. Leaving with changes asks first, and with changes in more than one form on view, one bar saves or discards them all. A screen shows no Save button of its own.

Something on a screen that someone should see before going on, a problem or a step still to take, is a Notice from @letterncms/ui (tone, icon, title, its text, an action and onDismiss): the same one a view's ui/notice draws. A failed request is a QueryError, with Retry.

#Forms are fields

Every form in the admin (sign-in, settings, dialogs, toolbars, search boxes, the block workspace inspector) is a list of field definitions drawn by Fields. Raw inputs live only inside field components (registry.fields), so a screen never hand-builds a control:

TypeScript
import { Fields, prompt } from '@letterncms/ui'

<Fields
  layout="inline"                      // compact rows, label left; default "stacked"
  className="flex items-center gap-2"   // a toolbar row
  fields={[
    { handle: 'q', type: 'text', label: 'Search', icon: 'search', size: 'sm', className: 'w-72' },
    { handle: 'status', type: 'select', label: 'Status', display: 'segmented', options: { all: 'All', open: 'Open' } },
  ]}
  values={filters}
  onChange={(handle, value) => setFilters({ ...filters, [handle]: value })}
/>

const answer = await prompt({ title: 'New list', fields: [{ handle: 'name', type: 'text', label: 'Name' }] })

A dialog of your own that asks for something is a form too: give Dialog an onSubmit and it wraps its body in the form, with Cancel and a button that says submitLabel (Enter submits too). submitDisabled keeps the button off until the form can go, danger makes it red, and busy spins it while the request runs, closing nothing meanwhile.

TypeScript
<Dialog open onOpenChange={setOpen} title={t('New Review')} onSubmit={create} submitLabel={t('Create Review')} submitDisabled={!title.trim()} busy={saving}>
  <Fields fields={[{ handle: 'title', type: 'text', label: t('Title'), autofocus: true }]} values={{ title }} onChange={(_, v) => setTitle(v ?? '')} />
</Dialog>

Presentation options the core components understand:

FieldOptions
Anyicon, size (xs for inspectors, sm, md, lg), placeholder, autofocus, autocomplete, inputmode, mono, className (the field's wrapper)
Textcommit: 'blur'
Selectdisplay: 'segmented' (with block), option objects with icon and title, numeric; placeholder names the option that applies while nothing is chosen (Browser Language, None) and leads the list, empty and action say what to do when there are no options
Toggledisplay: 'checkbox'
Numbersuffix and step
Datetime: true and an IANA timezone, such as Europe/Brussels. The control shows that zone, converts wall times to UTC instants, and rejects missing or ambiguous daylight-saving times with a visible error.
In inline rowsstatus (own or inherited) marks the label: a value set here reads in ink, an inherited one stays muted with a small dot. reset makes clicking the label clear the value

When core can't express a control (a CSS length with tokens, a color with alpha), register a field component and point a field at it with component. Core has one of its own, ease: an easing curve, picked from options (each drawn as its curve; curve draws a value that isn't one, such as a design token) or drawn by hand as a cubic-bezier.

#Your own field controls

Custom field controls use fieldControlId(field) from @letterncms/ui for their control ID and field.describedBy for aria-describedby. Fields supplies these values, so labels, errors and focus targets stay connected. Nested field renderers pass context.path with their parent handle (and the row index for repeaters), and keep context.errors: a nested seo.title must not reuse the document title's ID.

#Lists of records

In a view, a list is ui/records (see Views), drawn with DataView. In your own React, use DataView from @letterncms/ui for any overview of records. It gives you the same Table, Grid and Calendar views as content collections (Grid only with image, Calendar only with calendar), with search, sortable headers, a Display menu, saved column and view preferences, loading and error states, and keyboard navigation (j/k, x, Enter and Escape).

TypeScript
<DataView
  id="reviews"
  label={t('Reviews')}
  items={reviews}
  rowKey={(review) => review.id}
  rowHref={(review) => `/reviews/${review.id}`}
  searchText={(review) => review.title}
  columns={[
    { id: 'title', label: t('Title'), value: (review) => review.title, render: (review) => review.title },
    { id: 'rating', label: t('Rating'), value: (review) => review.rating, render: (review) => review.rating },
  ]}
  empty={<EmptyState title={t('No reviews yet')} />}
/>

Give each overview a stable id: preferences are saved per account under overview.<id>.

#Columns

  • The first column identifies the record and stays visible.
  • hiddenByDefault: true keeps an optional detail available in Display without showing it at first.
  • Use fixed or fractional column widths, so values can't shift the grid between rows.
  • value enables sorting of a complete local list.

#Larger lists

PropUse
sort, onSortFor paginated APIs: declare the API field in each sortable column's sort, and sort on the server before pagination.
toolbarServer search and filters.
selection={{ ids, onSelect }}Bulk actions, with SelectionBar.
pagination={{ page, pages, total, perPage, onPage }}Page controls (hidden in Calendar).
actionsThe same record controls in all three views.
imageA card preview. Without it there is no Grid.
calendarOffer a Calendar, for records that happen on a day (orders, events), not for settings-like lists.
defaultView="grid"Start with cards.

#Dates and the calendar

Published and Updated are standard columns, added only when the records have those dates: hidden at first outside content entry lists, and available through Display. Don't declare them in each screen.

Records implement the SDK's RecordTimestamps contract: publishedAt and updatedAt, as ISO 8601 strings or null. Unknown history and dates that don't apply show as —. Entries use their publication lifecycle; menus, redirects, collections, globals and forms are published when first saved; media when uploaded. Users, private submissions, audit events and package state have no publication date. Existing records keep unknown publication history as null. Saves track update dates on the server; bulk redirect saves keep unchanged rows as they were. Language metadata follows changes to each language's effective settings.

For wrapped rows, pass dates={(row) => row.record}. For server-paginated sorting, declare the supported fields with dateSort={{ published: 'published_at', updated: 'updated_at' }} alongside onSort. Otherwise the shared view sorts dates locally, newest first, with missing dates last. Content entry tables, trees and cards use the same date definitions and formatting.

Every overview uses OverviewControls for the same view choices, Display menu and Reset Display action. Calendar groups records by Published or Updated, chosen in Display and saved per overview. Records without the chosen date appear under No date. Month navigation and expandable busy days come from DataCalendar.

For a paginated source, pass source={{ ability: 'reviews/list', input: filters, perPage: 100 }}: the shared loader reads all matching pages through the ability cache, with at most four requests in flight, and handles loading, retries and invalidation. Don't build a screen-specific calendar, or silently render only the current page. Content collections only adapt their date-range filters; they use the same renderer and page loader.

For specialized collections such as trees, compose the same DataTable, TableFrame, TableRow, TableHeader, DataGrid, DataCard, DataCalendar and OverviewControls primitives. Add missing behavior to the shared primitives rather than introduce another overview pattern.

#Translations

The admin speaks English, Dutch and French, and every string you show should too. Write it in English and wrap it in t(): the English text is its own key.

TypeScript
import { plural, t } from '@letterncms/sdk'

<Button>{t('Send Test Email')}</Button>
toast.success(t('Sent to {email}', { email }))
plural(count, '{count} submission', '{count} submissions')

Ship the translations in your package as lang/<code>.json: { "Send Test Email": "Testmail versturen" }. The admin merges the core's and every active package's dictionaries for the user's language; a missing string shows in English.

PHP reads the same files. Use __('…') for messages you write. Labels you register (nav items, widgets, settings fields, permissions) are translated for you when they reach the admin.

Each person picks their Language in Account. Admin Language in Settings → General sets a default for everyone else, and without one the browser's language is used (English when Lettern doesn't speak it), the sign-in screen included. Dates and numbers follow it (locale() from @letterncms/sdk).

#In the workspace

The workspace's thread offers your abilities as commands, and draws what they answer with as views. Widgets are available to an agent automatically; for components it can fill in and layouts it composes, see Generative UI. View, Surface and WidgetSlot from @letterncms/ui give shared rendering across the Dashboard, the thread and other admin hosts: <View root={tree} embedded /> draws a view inside something else, and run lets that host run its buttons' abilities itself.

#What's on the screen

Something working alongside a screen, like an agent in the thread, can read what that screen is about. Any screen is known by its title and its text. A screen can say more, for as long as a component is mounted:

TypeScript
import { useScreenContext } from '@letterncms/sdk'

useScreenContext({
  kind: 'order',
  data: () => ({ id: order.id, status: order.status }),  // read only when it's needed; takes the place of the screen's text
  items: line ? [{ id: line.id, label: line.title, icon: 'cart', read: () => line }] : [],  // what else it offers, like the block selected in the builder
  flush: () => save(),                                   // bring it up to date before someone reads or changes it
})

The entry editor gives the entry and saves first; the block workspace offers the selected blocks. To read it, use useScreen(), or screenContext.get() and screenText(limit). A screen with something to keep to itself sets private: true on its route. After changing content from outside a screen, events.emit('content.changed') makes an open entry editor show the new version, unless it has unsaved edits.

#@ and / in a thread

/ offers commands: making things, places to go, settings pages, the commands of ⌘K and every ability the person may run. @ attaches context to a message, and is there only while an agent answers: a command or a search carries nothing. Everything a message carries is a ContextItem: an id, a label, optionally an icon, a kind, a to (an admin path to open it at) and read (what it says, called when a message goes out). The page on view, what a screen offers and what's picked with @ are all the same thing, shown as the same chip.

To offer your own under @ or /, register a source. It searches by what's typed after the trigger:

TypeScript
import { registry, run, t } from '@letterncms/sdk'

registry.composer.set('orders', {
  trigger: '@', label: t('Orders'), order: 30,
  search: async (query) => (await run('orders/search', { query, limit: 5 })).map((order) => ({
    id: `order:${order.id}`, kind: 'order', label: order.number, detail: order.customer, icon: 'cart',
    to: `/commerce/orders/${order.id}`,
    read: () => run('orders/get', { id: order.id }),
  })),
})

Reading through run keeps your ability's permission check. What picking a line does follows from what it has: perform runs it, insert writes text in the message, skill asks for that skill, command runs an ability in the thread, and without any of those it's attached. group gives a line a heading of its own.

A command names the ability, the input already chosen and the ability's input schema: what it requires and isn't chosen is asked for in the composer, generated from the schema (a choice is a select, yes or no a switch, a list of words tags), with required: true asking only for that. load asks with fields of your own and what they hold now instead (only what changed is sent), into puts the answers under one key, and confirm names the button. Field choices take their words from the schema's x-labels (see Asking for input):

TypeScript
{ id: 'new-review', label: t('New Review'), icon: 'plus', command: { ability: 'reviews/create', input: { status: 'draft' }, schema, required: true } }
{ id: 'review-settings', label: t('Review Settings'), command: { ability: 'settings/update', input: { namespace: 'reviews' }, into: 'values', load: () => run('settings/get', { namespace: 'reviews' }) } }

#Skills

Skills are registered on the server, and every agent gets them: / in a thread while the assistant answers there, prompts over MCP.

PHP
cms()->skills()->register('order-summary', [
    'label' => 'Summarize Orders',
    'description' => 'What sold this week, and what stands out.',
    'icon' => 'cart',
    'prompt' => 'Summarize the orders of {period}. Lead with what stands out.',
    'arguments' => [['name' => 'period', 'description' => 'Which days', 'required' => true]],
    'permission' => 'commerce.view',
]);

In a thread, the person's message gives the arguments.

#Block workspace

The entry.editor slot supplies the shared block workspace. The content host mounts it only when the collection explicitly names a body field whose type is blocks. It doesn't infer a body from field order or choose an editor based on a collection flag. Collections without a blocks body use the normal field form. A markdown field stays a raw text and code field, not another content workspace.

TypeScript
registry.panels.set('blocks-editor', {
  slot: 'entry.editor',
  label: 'Blocks',
  icon: 'blocks',
  component: lazy(() => import('./BlocksWorkspace').then((m) => ({ default: m.BlocksWorkspace }))),
  when: (p) => p.bodyField.type === 'blocks',
})

The host owns working content, saving, publishing, permissions and entry details. EntryEditorProps extends EntryPanelProps:

PropHolds
entry, collection, values, titleThe entry and its working values.
contentThe complete working snapshot inherited from EntryPanelProps: title, slug, data and authorId.
bodyFieldThe explicitly configured main-content field.
setValue(handle, value), setTitle(title)Change the working copy through the host.
save(), reload()Save now; reload from the server. Save failures reject, so extensions can handle them.
editable, can, errorsEditing access, action capabilities and field validation errors.
footerThe entry.body plugin panels that apply; render after the live surface in a bounded scrolling area.
sidebarHost-rendered entry details, History and plugin tabs.
sidebarOpen, setSidebarOpen(open)Sidebar visibility and its setter.

The workspace renders the supplied footer and sidebar rather than recreating metadata controls or tab ordering. The host keeps the entry title and collection fields in Details, with claimed fields in their plugin panels.

The body uses the existing site renderer, with rendered block IDs bridged to the shared store. A template's exact entry.title text binding can also edit the title inline through setTitle; both controls use the same working snapshot. Sample titles while editing templates or components stay read-only.

Selecting a block opens the shared Content, Design and Interact inspector; returning to entry details restores the host sidebar. Outline and insertion between rendered sections use the same complete catalog of compatible blocks. Rendered components, sample content and repeated items keep their source provenance rather than becoming editable nodes in the owning body. Preview doesn't own a second editing store.

#Share a module

A plugin can expose its module to others through the import map. The forms plugin builds on the block editor this way:

PHP
$this->adminModule(import: '@acme/reviews');

Other modules then import { … } from '@acme/reviews' and list it in externals of cmsAdminModule.