Building feature plugins
Aglyn features that add both console surface and site components ship
as a single library (AGL-277) — never merged into the core mui plugin, which
stays purely component and theme definitions for the Besigner and hosts.
libs/plugins/{feature} → Besigner/host components + the console page,
and both register* entry points
(published as @aglyn/plugins-{feature},
depends on @aglyn/plugins-mui)
Plugins live directly under libs/plugins/{feature} (moved out of the old
.../ui/ nesting in AGL-395) and publish as @aglyn/plugins-{feature}. A
plugin keeps its Besigner component, its console page, and both registration
functions in the one lib — the console half is a separate register*Console()
export, so the shell can register nav + pages at app load without pulling in
the Besigner canvas bundle.
Sharing app hooks. A relocated console page can't import console-app
hooks. Genuinely reusable ones move into a shared lib the plugin can import —
e.g. useFirestoreCollection, useFirestoreDoc, and useHostOrgId now live
in @aglyn/tenant-feature-instance, and HubTabs in @aglyn/shared-ui-next
(the app keeps thin re-export shims).
Reaching app-only UI (media browser). Some app UI can't move — the media
library is coupled to the org/session context. Instead of importing it, a
plugin consumes it through a context: MediaPickerContext /
useMediaPicker() in @aglyn/aglyn exposes pickMedia(): Promise<PickedMedia | null>. The shell mounts the app's ConsoleMediaPickerProvider (which owns
the real dialog) around every plugin page, so a plugin component just calls
pickMedia() and gets the chosen asset — see the commerce product editor.
This is the escape hatch for the dependency cascade a naive promotion would
hit.

The UI half
Build a bundle with defineUiFeatureBundle from @aglyn/aglyn (the
plugin-manager registration API, AGL-415) and register
it the same way the mui bundle registers itself. The bundle automatically
declares a dependency on the mui bundle, so primitives and theming load
first.
import * as Aglyn from '@aglyn/aglyn'
import { defineUiFeatureBundle } from '@aglyn/aglyn'
import * as EventList from './components/event-list'
export const BUNDLE_ID = 'events-calendar'
export function registerEventsCalendarPlugin(): void {
if (Aglyn.plugins.getDependency(BUNDLE_ID)) return
Aglyn.plugins.addDependency(
defineUiFeatureBundle(
{
bundleId: BUNDLE_ID,
displayName: 'Events Calendar',
components: [
{
component: EventList.default,
schema: EventList.schema,
presets: EventList.presets,
},
],
},
Aglyn.components,
),
)
}
componentId and pluginId are stored in page documents (the screens
collection). Never rename
them — when a component moves between bundles, keep the old ids resolving
(the mui plugin's legacy-id aliases are the precedent).
The console half
Export a ConsoleExtension and register it at console startup. The console
shell renders the nav items and their pages from the registry — and
applies the featureFlag entitlement gate itself, so an extension can't
bypass plans. A feature plugin adds a menu item to the host app bar and a
new page without editing any core console file (AGL-394).
The shell applies the permission gate the same way, for the other question:
featureFlag is what the organization bought, permission is what the person
reading may open. Name a key on the extension (or on one nav item, to narrow
that surface) and the shell refuses to construct the page for a reader who
does not hold it — see
the plugin-manager API reference.
Gating inside the page instead means the surface has already mounted and
opened its listeners before the check runs.
A nav item that carries a Component becomes a full page: the shell's
generic host route (apps/console/app/(app)/[orgSlug]/hosts/[host]/[...pluginSlug]/page.tsx)
mounts it under the active host, wires the breadcrumb/header, resolves the
featureFlag entitlement, and passes it in as entitled.
import { registerConsoleExtension } from '@aglyn/aglyn'
import { lazy } from 'react'
import { mdiCalendarMonthOutline } from '@aglyn/shared-data-mdi'
// Code-split: the manager UI only loads when the page opens.
const EventsConsolePage = lazy(() => import('./components/events-console-page'))
export function registerEventsCalendarConsole(): void {
registerConsoleExtension({
pluginId: 'events-calendar',
displayName: 'Events Calendar',
featureFlag: 'eventCalendar',
navItems: [
{
label: 'Events',
href: '/events', // host-relative → '/[hostId]/events'
navTabId: 'nav-tab-events', // reuse a release flag's staff-preview gate
icon: { path: mdiCalendarMonthOutline.path },
header: { title: 'Events' },
Component: EventsConsolePage,
},
],
dashboardCards: [{ cardId: 'events-upcoming', title: 'Upcoming events' }],
})
}
The page component receives ConsolePluginPageProps — { hostId, entitled, org, permissions, releaseFlag, basePath, sections, section, segments } — so it
stays free of console-app hooks. org is the resolved
org billing doc the shell already loaded, so the page can run its own
checkEntitlement/checkQuota (e.g. per-plan limits) without the app's
org/session hooks:
import { checkQuota } from '@aglyn/aglyn'
import type { ConsolePluginPageProps } from '@aglyn/aglyn'
export default function BookingsConsolePage({
hostId,
entitled,
org,
}: ConsolePluginPageProps) {
const quota = checkQuota(org, 'servicesPerHost', services.length)
// …authenticated Firestore reads/writes scoped to hostId
}
How the shell consumes the registry
apps/console/constants/register-console-plugins.tscalls each plugin'sregister*Console()at import time;_appimports it so the registry is populated before any nav renders. This registers only the console half — never the Besigner canvas bundle — so plugin canvas code stays out of the general console bundle.hostNavTabItemsspliceslistConsoleNavItems()into the host tab strip. Plugin tabs keep registration order unless an item declarestabOrder(lower first; absent is 0) — the organization strip reads it too, which is how Marketplace sits directly before Plugins. Nav gating is unchanged: an item'snavTabIdmaps to a release flag, so staff still preview flagged-off surfaces. The strip itself is built byuseSecondaryNav()from the current route and rendered once byapp/(app)/layout.tsx— a plugin never passes tabs to a page, and pages have no nav props to set (AGL-754/755).- The generic
[orgSlug]/hosts/[host]/[...pluginSlug]route resolves the page withresolveConsolePluginPage(href), renders it insideDashboardLayoutunderSuspense, and applies the release-flagFeatureGate. Named routes (setup, media, …) still win over this catch-all segment.
Routed sections (AGL-2501)
A surface can be a hub of real URLs instead of a tab strip. Declare sections
on the nav item and each becomes a route at ${href}/${section.id}:
navItems: [
{
label: 'Products',
href: '/products',
navTabId: 'nav-tab-commerce',
Component: CommerceConsolePage,
sections: [
{ id: 'catalog', label: 'Catalog' },
{ id: 'orders', label: 'Orders' },
// Ships on its own schedule — see "Gating" below.
{ id: 'insights', label: 'Insights', navTabId: 'nav-tab-logic' },
],
},
]
The shell resolves the URL and hands the page back:
section— the id the URL names, orundefinedon the surface's own href. It is always one of the declared ids: the shell 404s an id it does not recognize rather than falling back to the first section, so the page needs no branch for a section it does not have.sections— the declared list with an absolutehrefper section and the release verdict already applied tovisible. Feed it straight toHubSections; a plugin cannot computevisibleitself, because release flags live inscope:app.basePath— the surface's absolute path, e.g./acme/hosts/shop/products.segments— everything beneathbasePath.segments[0]issection; anything after it belongs to the section, so a section may own deeper routes (…/orders/ord_123) without another registry change.
The surface's own href (/products) names no section. Redirect it to the first
visible one rather than rendering that section in place — rendering it would pay
for its reads on a URL that is about to be replaced.
Why routes rather than tabs. A tab strip mounts every panel, so every panel's Firestore listens open on load and the reader is looking at one of them. The commerce console was the worst case: one load issued 32 listens with a ceiling of 4,462 documents to render one tab. A URL per section makes "mount only what is open" structural instead of a flag somebody has to remember — and a section becomes linkable, the back button walks sections, and which one is open is a fact about the URL rather than state kept in sync with it.
sections is optional, and omitting it is not a lesser option — it means the
surface is one page. A nav item without sections is matched exactly as it always
was, so a path beneath it is a 404 rather than the page rendered again.
Which registration owns a path
The registry is a session-wide union across plugins from different authors (AGL-758), so two plugins can claim overlapping paths. Resolution is:
- Longest declared
hrefwins, so an exact match always beats a section match — an exact href spans the whole path, and nothing matching a prefix of it can be longer. A plugin registering/products/orderstakes that URL from one registering/productswith anorderssection. - Prefixes match on a segment boundary only:
/productsnever claims/products-archive. - A tie refuses. Two enabled plugins matching the same path at the same length resolve to nothing and the console 404s, with both plugin ids logged. Registry insertion order is an accident of which chunk loaded first, so resolving by it would serve plugin A's page in one workspace and plugin B's in another. Two nav items of the same extension are not a tie — that order is authored, and the first wins.
Every candidate comes from listConsoleExtensions(enabledPluginIds), so a
plugin this workspace has not enabled can neither win a path nor collide on one.
Gating a section
A section inherits its nav item's navTabId — the flag that already gates
the surface. That is the common case and needs nothing.
A section may declare its own navTabId, which ANDs with the parent's: a
released surface can hold one unreleased section, but a section is never
reachable when its parent is not. Deep links are refused exactly as the nav is
hidden, and the rail is filtered from the same verdict, so a section this viewer
would be refused is not offered as a link.
Loading: org-gated and dynamic (AGL-417)
Apps never import @aglyn/plugins-*. plugins.config.json maps plugin ids
to packages and register entry points; tools/scripts/generate-plugin-manifests.mjs
emits the per-app loader manifests (plugins.*.generated.ts) — the ONLY
sanctioned plugin references outside libs/plugins (an nx scope:app
boundary rule enforces this). At runtime the core plugin loader activates
the plugins in org.enabledPlugins (AGL-416, org settings → Plugins):
console surfaces behind the providers gate, site/canvas surfaces behind the
editor/tenant suspense gates, API handlers lazily on first dispatch with a
per-request org gate (a disabled plugin's API 404s for that workspace).
Each first-party plugin is additionally release-flagged (AGL-422,
FirstPartyPlugin.releaseFlag): a flag staff turn off in the console
Feature Flags page subtracts the plugin from every workspace's effective
set — nav, editor, published sites, and API — with the usual staff preview
bypass. Register a flag for any new plugin (registry + Remote Config
template + the catalog entry).
Extending beyond pages: slots, providers, runtimes, hooks (AGL-418/419)
- Widgets —
ConsoleExtension.widgetsrender into named shell slots via the app'sPluginWidgetSlot. The guaranteed zones (and the props each receives) are the exportedCONSOLE_WIDGET_SLOTScatalog (AGL-433):hostActivity,hostDashboard,orgDashboard(the organization's Sites page, handed the org mount and no site),commerceGlance,orgData,besignerFunctions,marketplaceListing,orgAddons,dashboardFooter,orgSettings,hostSettings, and the staff-onlyadminOrgDetail. - Providers —
ConsoleExtension.providersmount around every console page (e.g. marketplace's AI-assist provider). - Site runtimes —
registerSiteRuntimecomponents run on every rendered tenant page (marketing's overlays/experiments/automations), reading back the props their server enricher wrote. - Site-page hooks —
/serverentries register redirect resolvers, page resolvers (commerce PDP/PLP), and enrichers into the tenant loader. - Billing webhook hooks —
registerBillingWebhookHandlerreceives the platform Stripe events (commerce orders, booking payments, marketplace purchases live in their plugins). - Permissions (AGL-435) —
registerPluginPermissions([{key, label, defaults: {admin, editor, viewer}}])contributes role-resolved keys: they ride every resolved permission set (ConsolePluginPageProps. permissions, API-side resolution), and custom roles override them key-by-key. Reference adopter: commerce'smanagePos. - Scheduled jobs (AGL-435) —
registerPluginJob({pluginId, name, intervalMinutes, handler})on the/serversurface; the deployment's scheduler POSTs/api/plugins/run-jobs(sharedPLUGIN_JOBS_SECRETheader, 501 when unconfigured) and due jobs run with error isolation. Keep handlers idempotent and bounded. Reference adopter: bookings'expire-stale-holds. - Custom field types (AGL-434) —
registerCustomFieldType({name, pluginId, label, baseType, Input, validate})adds a named field type to the dataset schema editor, riding an existing storage type (text/bool/ int32/float/map — no new storage primitives). The record editor mounts yourInput; yourvalidateruns after the base checks on both client and server. Register the pure-data half (noInput) from yourserverDeclarationsentry, which every app runs at boot, so each server write — the console's,/v1, a bound form's and an automation step's in the tenant — validates without loading your surfaces. Reference adopter: marketplace'srating. - Bootstrap phase (AGL-429) — export
bootstrap<Surface>()(bootstrapConsole,bootstrapSite, …) next to your register fn and the loader calls it after EVERY plugin in the batch has registered — the sanctioned place for cross-plugin wiring. Registers always precede bootstraps; a plugin bootstraps once per surface; late-loaded plugins bootstrap in their own batch, so read registries lazily. - Config schemas (AGL-428) — declare settings once with
registerPluginConfigSchema(pure-data schema module, registered from BOTH the client barrel and/serverentry) and the platform provides the rest: a generic settings form on the Plugins & add-ons hub, storage inorgs/{orgId}/pluginSettings/{pluginId}with per-site overrides inhosts/{hostId}/pluginSettings/{pluginId}, and defaults-merged type-coerced reads viagetPluginConfig(server) andusePluginConfig/useSitePluginConfig(client). PasshostIdtogetPluginConfigwherever you have one, or the site's override is ignored. Reference adopter: bookings'maxDaysAheadhorizon. Full contract: Plugin configuration.
Remote bundles: the trusted realm tier (AGL-420)
Marketplace plugins normally run sandboxed in the cross-origin
PluginFrame. Listings a staff member has reviewed and signed
(trust: 'realm' + an Ed25519 signature over the bundle's sha256 on the
version doc) instead load INTO the app realm and use every registry above —
first-party-grade extensions installed per workspace, no repo change.
- Build with
tools/plugin-loader/realm/rollup.config.mjs: yourreact,react/jsx-runtime, and@aglyn/aglynimports compile to lookups onglobalThis.__AGLYN_PLUGIN_HOST__(the app's own singletons), so the emitted bundle imports nothing. Exportregister(host)for client surfaces;registerApi()for server handlers. - Publish through the marketplace pipeline, install (pins
{version, sha256}), then staff grant trust viaPOST /api/marketplace/admin/trust. - The console loads an org's realm installs before the shell renders;
published sites load them post-hydration (additive runtimes — first
paint never waits on a marketplace CDN). Every load verifies the sha256
pin and the platform signature and fails closed;
revocationsis the hard kill switch. - Server handler bundles are additionally gated by
PLUGIN_REMOTE_SERVER=enabled+ a per-deployPLUGIN_REMOTE_SERVER_BUNDLESallowlist, default off everywhere.
Full trust chain, env matrix, and walkthrough: docs/PLUGIN_LOADING.md in
the repo; a runnable demo lives at tools/plugin-loader/realm/demo.
The server half (API routes)
A feature's server logic — Next.js API routes backed by firebase-admin — moves into its plugin the same way, through the API-route registry (AGL-396), the server counterpart to the ConsoleExtension registry.
// libs/plugins/{feature}/src/lib/server.ts — a SEPARATE entry point that
// pulls in firebase-admin; never re-export it from the client barrel.
// Import from `@aglyn/aglyn/server` (context-free), NOT the `@aglyn/aglyn`
// barrel: the tenant's dispatcher is an App Router route handler, whose
// module graph forbids `createContext`, and the full barrel re-exports the
// client React contexts (AGL-405/408).
import { registerPluginApiRoute, type PluginApiHandler } from '@aglyn/aglyn/server'
import { firebaseAdmin } from '@aglyn/tenant-data-admin'
const listHandler: PluginApiHandler = async (req, res) => {
// req/res are structural (not `next`) types, so the plugin stays
// framework-light; NextApiRequest/Response satisfy them.
res.status(200).json({ events: [] })
}
export function registerEventsCalendarApi(): void {
registerPluginApiRoute('events/list', listHandler)
}
Each Next app ships one catch-all dispatcher that imports a server-only
registration module (register*PluginApis()) and resolves the request path
against the registry. Both apps are on the App Router
(app/api/[...pluginApi]/route.ts, AGL-408/410); the same PluginApiHandler
runs unchanged in either. The dispatcher runs the handler through the
runLegacyHandler adapter (@aglyn/aglyn/server, AGL-407), which bridges the
Web Request/Response to the framework-light (req,res) contract:
// app/api/[...pluginApi]/route.ts (both apps)
import { resolvePluginApiRoute, runLegacyHandler } from '@aglyn/aglyn/server'
import '../../../utils/register-plugin-apis' // side-effect registration
async function dispatch(request, { params }) {
const { pluginApi } = await params
const route = resolvePluginApiRoute((pluginApi ?? []).join('/'))
if (!route) return Response.json({ error: 'Not found' }, { status: 404 })
return runLegacyHandler(route, request, { pluginApi }) // handler unchanged
}
export { dispatch as GET, dispatch as POST, dispatch as PUT, dispatch as PATCH, dispatch as DELETE }
Named API routes win over the catch-all, so URLs are preserved: to
migrate /api/events/list, delete the named route file and register the
handler — the dispatcher serves the same URL. Import the plugin's /server
entry only from the dispatcher's registration module (server code); the
client barrel must never pull it in, or firebase-admin leaks into the browser
bundle. Reference: libs/plugins/events-calendar/src/lib/server.ts.
Both Next apps carry a dispatcher. The tenant (site-facing) app registers
via registerTenantPluginApis; the console (authoring) app has its own
dispatcher + registerConsolePluginApis, and plugins expose a separate
register*ConsoleApi() for console-only handlers (staff/merchant ops, cron
jobs) so each app registers only what it serves. A migrated route keeps its
/api/... URL in whichever app owned it.
Raw-body routes. On the App Router a handler reads the raw body directly
(request.text()/arrayBuffer()), so webhooks (Stripe billing/webhook,
Svix email/events) and size-capped uploads need no bodyParser config —
the old Pages Router export const config exceptions are gone; byte caps
are enforced in-handler.
Build-time evaluation gotcha (AGL-410). App Router route modules are
evaluated during next build (page-data collection) — unlike Pages API
routes, which never were. Module-scope side effects in a route's import
graph must therefore tolerate a credential-less build: the firebase-admin
init in @aglyn/shared-util-fbserver skips when the full credential is
absent, and server libs must resolve Firestore/RTDB/Auth handles lazily
inside functions, never at module scope.
Shared server runtime (@aglyn/tenant-runtime)
Some handlers need tenant runtime that no single plugin owns — the host-event
fan-out (emitHostEvent, dispatchHostAutomation, and the listener registry
registerHostEventListener that a plugin joins from its serverDeclarations
entry) and the server-side page-composition read-path (getScreen,
composeScreenNodes, and the get-* loaders behind it). The runtime raises
events and runs none of what they trigger: the automation engine is the
Workflows plugin's listener. An event can carry who caused it beside its
payload — emitHostEvent(hostId, event, payload, { actor }), where actor is
{ kind: 'member' | 'visitor' | 'apiKey' | 'platform', uid?, email?, apiKeyName? }
— and a listener receives it as onEvent's fourth argument. A door that knows
who acted should always pass it: the run history's Who column reads it. These live in @aglyn/tenant-runtime, a
server lib tagged scope:lib+scope:aglyn so both the tenant app's own API
routes and any plugin server.ts can import it (and it, unlike the
scope:data tenant-data-admin, may import @aglyn/aglyn). The host-event
functions come from the package root; the composition pipeline is reached via
subpath, e.g. @aglyn/tenant-runtime/compose-screen-nodes:
import { emitHostEvent } from '@aglyn/tenant-runtime'
import composeScreenNodes from '@aglyn/tenant-runtime/compose-screen-nodes'
The bookings book, events dispatch, and commerce membership/* handlers
are reference consumers.
Project setup
Scaffold a new first-party plugin instead of hand-copying one (AGL-425):
node tools/scripts/create-plugin.mjs my-plugin \
--label "My Plugin" --surfaces console,tenantApi
That generates the complete libs/plugins/my-plugin library (correctly
tagged, with register entries per surface and a passing spec), registers it
in plugins.config.json + the tsconfig aliases, re-runs the manifest
codegen, and prints the two manual follow-ups (catalog entry + release
flag). Marketplace/marketplace authors start from
tools/plugin-loader/realm/template instead — a standalone npm package
that builds a host-ABI realm bundle.
Conventions the scaffold already applies:
- Tag new plugin libs with exactly
["aglyn:addons"]— nothing else (AGL-409). This single tag is a plugin's whole module-boundary identity: as a dependency target no core scope's allowlist reaches it, sonx lintrejects any core lib importing@aglyn/plugins-*(the app can run without the plugin). As a source, theaglyn:addonsrule still lets a plugin import any lib and other plugins. Do NOT addscope:lib/scope:aglynback — that reopens the hole (every lib carriesscope:lib). - Plugins are wired in only through the generated loader manifests
(
plugins.config.json→plugins.*.generated.ts, AGL-417); core libs and app feature code must never import a plugin. - Plugin libs may import
@aglyn/plugins-muifor primitives; nothing may import a feature plugin from@aglyn/plugins-mui(that direction is the anti-pattern this rule exists to stop).
Reference implementations
- Events calendar (
libs/plugins/events-calendar) — the reference: its whole console page lives in the plugin (AGL-313/394). - Bookings (
libs/plugins/bookings) — the second full extraction (AGL-395): thebookingcanvas component moved out ofplugins-muiand the bookings manager out of the app; the page reads plan limits via theorgprop +checkQuota. - Redirects (
libs/plugins/redirects) — a console-only plugin (AGL-395): redirects enforce server-side (ISR), not through a canvas component, so it exports onlyregisterRedirectsConsole()and ships no UI bundle — nothing to register in the tenant/besigner. The minimal shape when a feature has console surface but no site component. - Logic (
libs/plugins/logic) — console-only (AGL-395): variables + no-code functions and the reference-integrity audit. It also exports shared tooling — the where-used dialog and its fetch util, plus the variable/function cards — which the app's workflows surface and besigner ƒx button import from@aglyn/plugins-logic. Always-on (not release-flagged). - CRM (
libs/plugins/crm) — console-only (AGL-395): one nav item whose sections — contacts, leads, companies, deals, tasks, reports, fields — are routes the shell resolves and gates, each with record pages beneath it. The plugin id iscrm; it wascontactswhile the surface was one list, and the old id is still read from every org's enabled-plugins list. TheFeatureGateand layout wrapper come from the shell; the contacts section reads thecontactsPerHostquota off theorgprop. - Inbox (
libs/plugins/inbox) — console-only (AGL-395): form-submissions reader, site members + leads, and the borrowed Campaigns section. Its three sections are routes rather than tabs (AGL-2501), declared ininbox-console-sections.tsand registered on the nav item. Depends on@aglyn/plugins-marketing— a plugin can compose sections from other plugins the same way the app did. - Marketplace (
libs/plugins/marketplace) — console-only, multi-page (AGL-395): the plugin owns the hub page and its cards +useMarketplaceActions, but the listing/publisher detail pages stay as app file-routes (nested dynamic segments the single-segment plugin route can't serve) and import the hook from the plugin. It's whyConsolePluginPagePropsalso carriespermissions(shell-resolved) — the install action gates oninstallPlugins. - Marketing (
libs/plugins/marketing) — console-only (AGL-395): the at-a-glance rollup, overlay/announcement/popup managers, and A/B testing. The first relocated plugin to consume the media browser — the popup image picker callsuseMediaPicker()(the shell mounts the provider around plugin pages), so the app media dialog never leaves the console app. - Automation (
libs/plugins/workflows) — console-only (AGL-395): the workflow builder, actions builder, and webhooks tabs, plus the sharedHostActivityCard(exported for the app dashboard and a page's detail view). Each tab gates on its own plan flag (workflows / actions / webhooks), so all three read the passedorgrather than a singleentitled. Asks the platform's where-used scan what a workflow computes; the logic plugin answers it through its dependents source and draws the dialog in theworkflowUsagezone, so neither plugin imports the other. - Data (
libs/plugins/data) — console-only, and dual-surfaced (AGL-395): the datasets editor is served both as the host/dataplugin page and, because datasets are org-scoped, imported directly by the org/org/dataapp route. The card takes the org doc as a prop so both callers drive its entitlement/quota checks.useHostActivityLoggerwas promoted to@aglyn/tenant-feature-instancefor the move. - Email (
libs/plugins/email) — full console relocation (AGL-395): the campaigns composer, audience lists, and a dedicated list of designed emails moved into the plugin and surface as the Emails page; the Besigner offers only email-safe blocks when editing an email document. - Commerce (
libs/plugins/commerce) — full console relocation (AGL-395): all Products management cards + theCommerceConsolePagelive in the plugin; the product editor reaches the console media browser throughuseMediaPicker().