A slot is a named extension point. One plugin defines it and renders it; others contribute items as plain data. No import between them beyond the slot token.
// @orthacms/utils-admin
function createSlot<T>(name: string): Slot<T>; // define
type SlotContribution<T> = { slot: Slot<T>; items: T[] }; // contributeFilling a slot
import type { AdminPlugin } from '@orthacms/bootstrap-admin';
import { SIDEBAR_NAV_SLOT } from '@orthacms/shell-admin';
import { COPILOT_TOOL_RESULT_SLOT } from '@orthacms/copilot-admin';
import { MessageSquare } from 'lucide-react';
import { ReviewListResult } from './ReviewListResult';
export function ReviewsPlugin(): AdminPlugin {
return {
name: 'reviews',
slots: [
{
slot: SIDEBAR_NAV_SLOT,
items: [
{
labelId: 'reviews.nav.label',
defaultLabel: 'Reviews',
to: '/reviews',
group: 'overview', // or 'directory'
order: 40,
icon: MessageSquare,
permission: 'content:read'
}
]
},
{
// Render the review_list tool's output as a card in the copilot chat
slot: COPILOT_TOOL_RESULT_SLOT,
items: [{ id: 'reviews.copilot.list', toolName: 'review_list', Component: ReviewListResult }]
}
]
};
}// ReviewListResult.tsx — receives the call's name, input and output
import type { CopilotToolResultContext } from '@orthacms/copilot-admin';
export function ReviewListResult({ output }: CopilotToolResultContext) {
const { items } = output as { items: { id: string; body: string }[] };
return <ul>{items.map((r) => <li key={r.id}>{r.body}</li>)}</ul>;
}The item’s type is the slot’s. Two common shapes:
- Nav items —
labelId,defaultLabel,to,order,icon, optionalpermission. - Component items —
id,order(on most),Component.
The catalogue
App shell — @orthacms/shell-admin
| Slot | Fills | Item |
|---|---|---|
SIDEBAR_NAV_SLOT | Top-level sidebar entries | Nav item + group (overview or directory) |
SIDEBAR_SECTION_SLOT | A whole sidebar section | { id, order, Component } |
SIDEBAR_FOOTER_SLOT | The sidebar footer | { id, order, Component } |
HOME_SECTION_SLOT | The home page, by region: action, attention, stat or panel | { id, region, order, Component } |
COMMAND_SLOT | A result group in the command palette | Component item; receives { close } |
Inside a workspace — @orthacms/workspaces-admin
Paths and to are relative to /workspaces/:id.
| Slot | Fills | Item |
|---|---|---|
WORKSPACE_NAV_SLOT | Workspace sidebar entries | Nav item |
WORKSPACE_SECTION_SLOT | A workspace sidebar section | { id, order, Component, takeover? } |
WORKSPACE_ROUTE_SLOT | Routes in the workspace shell | { path, element, order? } |
WORKSPACE_SETTINGS_TAB_SLOT | A section of the settings page, listed in its side nav | { id, path, labelId, defaultLabel, descriptionId?, defaultDescription?, icon, order, permission?, element } |
A section’s optional takeover(pathname) (since 0.13.0) makes it the
sidebar’s only content wherever it returns true: the other sections and the
Tools group step aside, and the workspace header stays. The copilot’s Agents
view swaps the content tree for its chat list that way.
Content library — @orthacms/content-admin
Eighteen slots. Content defines them and fills none itself. The entry-editor
components receive EntrySlotContext — schema, entry, isCreate, mode,
workspaceId, typePath, params, tabSegment.
| Slot | Fills |
|---|---|
RECORDS_TOOLBAR_SLOT | Controls in the records table’s toolbar |
RECORDS_MENU_SLOT | Whole-collection actions, in the toolbar’s ⋯ menu |
RECORDS_COLUMN_SLOT | Extra columns in the records table |
RECORDS_FILTER_FIELDS_SLOT | Virtual filterable fields |
RECORDS_BULK_ACTION_SLOT | Actions on a selection |
ENTRY_TAB_SLOT | A view of the record, as a tab between Content and History |
ENTRY_HEADER_SLOT | A control leading the top bar’s actions (beside the title on a phone) |
ENTRY_STATUS_ITEM_SLOT | A status item the header’s status cluster lists |
ENTRY_TIMELINE_SLOT | Rows between the versions of the History timeline |
ENTRY_SIDEBAR_WIDGET_SLOT | A block in the Properties panel — render EntrySidebarSection |
ENTRY_DETAILS_ROW_SLOT | A row in the panel’s Details block — render EntrySidebarRow |
ENTRY_FIELD_CONTROL_SLOT | The control a field type renders as, in the one Content form |
ENTRY_MENU_SLOT | Items in the entry’s action menu |
ENTRY_PARAMS_SLOT | Extra query parameters on entry requests |
ENTRY_PRESAVE_SLOT | A hook that runs before a save |
ENTRY_PUBLISH_GUARD_SLOT | A verdict on whether this entry may be published, and why not |
REVISION_EXTRA_SLOT | Rows in a revision preview |
CONTENT_OVERLAY_SLOT | An overlay over the content area |
Elsewhere
| Slot | Package | Fills |
|---|---|---|
INSIGHTS_WIDGET_SLOT | @orthacms/insights-admin | A card on the Insights dashboard |
INSIGHTS_SECTION_SLOT | @orthacms/insights-admin | A section of the dashboard, drawn as a tab |
INSIGHTS_SIGNAL_SLOT | @orthacms/insights-admin | A line in Needs attention, and the drill-down panel behind it |
WYSIWYG_MEDIA_SLOT | @orthacms/wysiwyg-admin | A media source in the rich-text editor |
COPILOT_TOOL_RESULT_SLOT | @orthacms/copilot-admin | A rich rendering of a tool’s result, by toolName |
There is no general top-bar slot: a page fills its own bar with
PageActionsPortal, and Home’s bar holds the action items of
HOME_SECTION_SLOT. The copilot’s launcher — Ask Ortha AI, in the
strip under the work area since 0.9.0 — is a SIDEBAR_FOOTER_SLOT item that
places itself with the design system’s InsetFooterEnd — not an extension
point you can fill.
The entry editor’s slots
Since 0.12.0 the entry editor is one Content form in schema order, with History and any contributed tab beside it, and its properties in a Properties panel that opens over the form on demand (ADR-0022). The slots follow that shape.
Status items — plugins report, content draws. An
ENTRY_STATUS_ITEM_SLOT item is an EntryStatusItemContribution:
type EntryStatusSeverity = 'blocking' | 'warning' | 'info';
type EntryStatusItem = {
id: string;
severity: EntryStatusSeverity;
label: string; // already localized: "Review requested"
detail?: string; // one line under it in the list
target?: { field: string } | { label: string; onSelect: () => void };
};
type EntryStatusItemContribution = {
id: string;
order?: number; // among items of the same severity
appliesTo?: (schema: ContentTypeDetail) => boolean;
useItem: (context: EntrySlotContext, state: { dirty: boolean }) => EntryStatusItem | null;
};useItemis a hook, called on every render, so it may run queries. Returnnullwhen there is nothing to say.- Content merges the items with the form’s own field issues (one “N issues” chip) and a publish guard’s refusal, and draws the cluster: at most two chips, most severe first, the rest behind +N, and the full list grouped Blocks publishing / Heads-up. A field target gets Go to field. A plugin never draws a header chip itself.
- An
infoitem takes no chip while something blocks publishing. @orthacms/alarms-adminreports the record’s open check findings — awarningwhen one is an error, elseinfo. Alarms never block.
Timeline sources. An ENTRY_TIMELINE_SLOT item is
{ id, label, useEvents }. useEvents(context) returns
{ events, isPending, isError }, or null when the reader may not see the
source — then History offers no filter for it. Each event is
{ id, at, title, actor? }, already localized; content sorts and groups them
by day between the versions. @orthacms/activity-admin contributes the
record’s activity, filtered as Activity beside All and Versions.
Field controls. An ENTRY_FIELD_CONTROL_SLOT item owns the input; content
keeps the label row, the description and the error. Since 0.12.0 the control’s
context also carries mediaRefs and mediaRefsPending (the saved entry’s
resolved refs for that field) and presave (the ENTRY_PRESAVE_SLOT handles,
by item id). An item that sets composite: true is several controls rather
than one input, so its field is named as a group instead of by a <label for>.
@orthacms/media-admin renders media fields this way, inline in the form,
and no longer adds a tab of its own.
Tabs are views, not kinds of field. ENTRY_TAB_SLOT slugs are a closed set
(ENTRY_TAB_SLUGS). @orthacms/segments-admin’s Access is the one shipped
contribution; media and activity no longer add tabs. relations, media and
activity stay in ENTRY_TAB for one release as redirect-only slugs:
…/relations and …/media open the Content form, …/activity opens History
with ?show=activity.
The Properties panel. ENTRY_SIDEBAR_WIDGET_SLOT and
ENTRY_DETAILS_ROW_SLOT keep their contracts and render in the Properties
panel. Do not put anything there that a reader needs to decide a save or a
publish: the panel is closed until opened.
What this buys
- The rich-text editor is one
ENTRY_FIELD_CONTROL_SLOTitem — no route, no nav entry. It appears wherever an entry form does. The media field is another. - Insights ships no widgets. Content, media, i18n and protection fill its widget slot. The dashboard never learns about them.
Rules
- Boot-frozen. Items are wired once at boot; nothing adds or removes one at runtime. That is what lets a render site call a hook per item in a loop.
- Gate on the item and inside the component. The renderer must know whether a section has visible items before drawing its heading.
- Sort when you render.
getItems()returns registration order; the shipped render sites sort withbyOrderfrom@orthacms/utils-admin.
Defining your own
// packages/reviews/admin/src/lib/reviewsSlots.ts
import type { ComponentType } from 'react';
import { createSlot } from '@orthacms/utils-admin';
export type ReviewsPanelItem = {
id: string;
order: number;
permission: string;
Component: ComponentType; // zero props: read what you need from context
};
export const REVIEWS_PANEL_SLOT = createSlot<ReviewsPanelItem>('reviews.panel');// where you render it
import { byOrder } from '@orthacms/utils-admin';
import { useHasPermission } from '@orthacms/identity-admin';
import { REVIEWS_PANEL_SLOT, type ReviewsPanelItem } from './reviewsSlots';
const items = byOrder(REVIEWS_PANEL_SLOT.getItems());
function PanelItem({ item }: { item: ReviewsPanelItem }) {
const allowed = useHasPermission(item.permission);
return allowed ? <item.Component /> : null;
}
export function ReviewsPanels() {
return items.map((item) => <PanelItem key={item.id} item={item} />);
}Export the token and item type from your package index so other plugins can import them.
Prefer a zero-prop component
Props are a contract both sides must change together; context is one the
render site already provides. The entry-editor slots are the exception — their
components receive EntrySlotContext, because only the editor knows which
record is open.