UI
How a native worker is built and run. Targets, lifecycle, the components and hooks it can use, the limits on each call, and block settings and translations.
On this page
A worker is React code that draws with Poltura's components instead of HTML. It runs in a separate, secure environment, not in the admin's or the storefront's page, and sends the platform a description of what to draw; the admin or the theme draws it with its own components. That is why a worker cannot reach the page's DOM, cookies or network, and why it looks native wherever it appears.
Every worker file starts the same way: import @poltura/app-ui/worker first, then render your root component
with startUiWorker from @poltura/app-ui/worker-runtime.
Targets
Admin: admin.page, admin.home, admin.product, admin.collection, admin.order, admin.customer.
Storefront templates: storefront.home, storefront.products, storefront.product, storefront.cart, storefront.checkout, storefront.login, storefront.register, storefront.account, storefront.contact, storefront.page.
Layout: storefront.layout.header, storefront.layout.footer.
Header zones are a full-width row of their own under the navigation, outside the sticky header, on mobile too: a taller worker pushes the page down instead of covering it. Footer zones sit in the page flow as well.
App page: storefront.app-page.
A release holds at most 16 workers, each at most 1048576 bytes. A rendered snapshot is at most 32768 bytes, 8 levels deep, 128 nodes, and 24 children per node.
Lifecycle
When a worker mounts, the platform sends it the session and its context (locale, settings, theme). startUiWorker
renders your component and sends a snapshot after each render. From then on the worker receives events (a
press, a change), context updates when settings change, and the answers to its capability calls. When the
merchant or shopper leaves, the worker is disposed. Every capability call is checked again against the
installation before the platform acts on it.
The complete example's admin page:
export function AdminPage(): ReactNode {
const copy = copyFor(useLocale());
const settings = useSettings();
const navigate = useNavigate();
const toast = useToast();
const pick = useResourcePicker();
const saveFeatured = useAction('save_featured');
const heading = String(settings.heading ?? copy.heading);
const [picked, setPicked] = useState('');
return (
<Card className='offer-panel rounded-theme bg-surface text-foreground p-4'>
<Stack>
<Heading>{heading}</Heading>
<Text>{copy.adminIntro}</Text>
<TextField label={copy.featuredLabel} value={picked} onChange={setPicked} />
<Inline>
<Button
label={copy.pickProducts}
onPress={async () => {
const ids = await pick('product', true);
if (ids === null) return;
setPicked(ids.join(','));
await toast('success', copy.pickedToast);
}}
/>
<Button
label={copy.save}
onPress={async () => {
const productIds = picked
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
await saveFeatured({
productIds,
expectedVersion: 0,
idempotencyKey: crypto.randomUUID(),
});
await toast('success', copy.savedToast);
}}
/>
<Button
label={copy.openPage}
onPress={() => navigate({ kind: 'app_page', pageId: RECOMMENDATIONS_PAGE })}
/>
</Inline>
</Stack>
</Card>
);
}export function AdminProduct(): ReactNode {
const copy = copyFor(useLocale());
const selected = useSelectedEntity();
const navigate = useNavigate();
if (selected.status !== 'ready' || selected.entity.kind !== 'product') {
return <Text>{copy.unavailable}</Text>;
}
return (
<Card className='rounded-theme bg-surface text-foreground p-4'>
<Stack>
<Heading>{selected.entity.product.title}</Heading>
<Button
label={copy.openSettings}
onPress={() => navigate({ kind: 'app_page', pageId: RECOMMENDATIONS_PAGE })}
/>
</Stack>
</Card>
);
}export function StorefrontProduct(): ReactNode {
const copy = copyFor(useLocale());
const settings = useSettings();
const theme = useTheme();
const cart = useCart();
const toast = useToast();
const host = useStorefrontContext();
const recommend = useAction('recommend');
const heading = String(settings.heading ?? copy.alsoLike);
const storeName = theme?.settings?.store_name;
const [contextLabel, setContextLabel] = useState(copy.alsoLike);
useEffect(() => {
void host.read().then(async (context) => {
if (context.kind === 'product') setContextLabel(context.product.title);
const suggestion = await recommend({
productId: context.kind === 'product' ? context.product.id : null,
});
const title = z.object({ title: z.string().min(1) }).safeParse(suggestion);
if (title.success) setContextLabel(title.data.title);
});
}, [host, recommend]);
return (
<Card className='offer-panel rounded-theme bg-surface text-foreground p-4'>
<Stack>
<Heading className='font-display'>{heading}</Heading>
<Text>{z.string().min(1).safeParse(storeName).data ?? contextLabel}</Text>
<Button
label={copy.addToCart}
className='bg-primary text-on-primary'
onPress={async () => {
const parsed = VariantId.safeParse(settings.variant_id);
if (!parsed.success) {
await toast.show({ tone: 'warning', message: copy.cartRejected });
return;
}
const reply = await cart.add({ variantId: parsed.data, quantity: 1 });
if (reply.status === 'ok') {
await toast.show({ tone: 'success', message: copy.added });
return;
}
await toast.show({ tone: 'warning', message: copy.cartRejected });
}}
/>
</Stack>
</Card>
);
}Primitives
Node types: Stack, Inline, Card, Text, Heading, Button, TextField, Select, Checkbox, Alert, Divider. className max 2048, no control characters. Text/Heading/Alert children become content (max 2000 characters). Heading level is 2 | 3 | 4. Button label max 160 characters. Select options 1 to 24. onChange receives a value, not a DOM event. HTML and DOM libraries are not supported.
Hooks
| Hook | Entry | Returns |
|---|---|---|
useSettings | admin, storefront | Resolved placement settings (Record<string, string | number | boolean | null>, max 64 keys) |
useLocale | admin, storefront | LocaleTag string |
useTheme | admin, storefront | Optional UiAppearance (mode, tokens, theme, declared public settings) |
useExtensionContext | admin, storefront | Full UiExtensionContext |
useSelectedEntity | admin | { status: 'loading' | 'unavailable' } or { status: 'ready', entity } for product | collection | order | customer |
useHostContext | admin, storefront | { status, context?: UiTargetContext } |
useNavigate | admin | (destination: UiNavigateDestination) => Promise<void> (app_page, host with a host target, storefront_path) |
useNavigate | storefront | { to(path): Promise<void> } (StorefrontNavigatePath) |
useToast | admin | (tone: UiToastTone, message) => Promise<void> (message max 200) |
useToast | storefront | { show({ tone, message }) } |
useResourcePicker | admin | (kind: 'product' | 'collection', multiple) => Promise<string[] | null> (ids max 50) |
useCart | storefront | { read, add, update, remove } |
useStorefrontContext | storefront | { read(): Promise<UiTargetContext> } |
Capabilities and policy
Each capability has a timeout, a limit on calls in flight, a request window, and the surfaces it works on:
| Action | timeoutMs | maxPending | windowMs | windowRequests | surfaces |
|---|---|---|---|---|---|
read_selected_entity | 5000 | 2 | 10000 | 8 | admin |
read_context | 5000 | 2 | 10000 | 8 | admin, storefront |
navigate | 3000 | 1 | 10000 | 8 | admin, storefront |
toast | 2000 | 2 | 10000 | 16 | admin, storefront |
pick_resource | 15000 | 1 | 10000 | 4 | admin |
cart.add | 5000 | 2 | 10000 | 8 | storefront |
cart.update | 5000 | 2 | 10000 | 8 | storefront |
cart.remove | 5000 | 2 | 10000 | 8 | storefront |
cart.read | 3000 | 2 | 10000 | 16 | storefront |
A capability result is at most 16384 bytes. Cart quantity 1–99. UiCartRejectionReason: out_of_stock, invalid_variant, limit, unavailable. Replies: ok, unavailable, cancelled, rejected.
Settings and translations
A worker's settings are its element definition's settings, written with defineBlock and field.* from @poltura/app-sdk/elements (the same format theme blocks use). poltura app build reads the definition statically, never runs it, and writes it into the worker's element (the public element definition in @poltura/app-contracts/ui-element) without the type key. The customizer shows label, description, icon and category in the picker, the settings in the inspector, style in the Style tab, and presets on insert: only a preset with the id default is applied by the plain tile, and every other preset is its own tile after it.
| Rule | Contract |
|---|---|
| Field types | text, textarea, number, range, checkbox, select, radio, color, url, header, paragraph |
| Builders | field.text, textarea, number, range, checkbox, select, radio, color, url, group (header), note (paragraph) |
| Setting ids | snake_case or kebab-case, max 64 characters; credential-shaped and reserved names fail |
| Fields | max 32; the whole definition is at most 16384 bytes |
| Values | text, textarea, url, color max 2000 characters; select and radio take an option value; number and range stay inside min/max; checkbox is boolean |
| Options | 1 to 20 unique values |
| Style tab | layout, spacing, size, typography, color, background, border, effects, visibility |
| Icons and categories | Chosen from the platform's icon and category lists |
| Presets | max 8; preset settings must be valid values of declared fields |
Media, rich text, resource pickers, darkDefault, child elements (blocks, slots) and templates are not available: the worker target decides the template. header and paragraph carry no value. Fields without a default are absent until the merchant sets them. A worker without an element definition receives no settings.
Labels, descriptions, option labels and preset labels are your base-language copy; the platform translates them for each store language. Merchants translate text values per locale in settings_i18n, validated against the same definition on theme save.
Manifest translations[] cover displayName / description. Page translations[] cover titles. Worker copy is your JSON; useLocale() returns the host locale. This example loads src/locales/en.json and src/locales/hu.json:
{
"heading": "Recommendations",
"adminIntro": "Pick catalog products, then save. Native workers cannot call your origin; useAction save_featured relays to your backend.",
"featuredLabel": "Featured product ids",
"pickProducts": "Pick products",
"save": "Save",
"savedToast": "Featured products saved.",
"pickedToast": "Products selected.",
"openPage": "Open app page",
"unavailable": "No product is selected.",
"openSettings": "Open recommendations",
"alsoLike": "You may also like",
"addToCart": "Add to cart",
"added": "Added to cart.",
"cartRejected": "The cart could not add that variant.",
"emailLabel": "Email",
"subscribe": "Subscribe",
"invalidEmail": "Enter a valid email address.",
"subscribed": "Subscription captured.",
"pageIntro": "This storefront app page is /apps/recommendations from storefrontPages slug recommendations.",
"shopProducts": "Browse products",
"footerHelp": "Footer capture stays on this mount. It does not call /apps-api/v1."
}export function copyFor(locale: string): CopyTable {
const table = locale.toLowerCase().startsWith('hu') ? hu : en;
return CopyTable.parse(table);
}Hosted frames
adminFrame.actions is a subset of these bridge actions: toast, set_loading, navigate_host, pick_resource, resize, get_session_token, get_appearance. Hosted pages that want the same primitives without a worker use @poltura/app-ui/dom and get_appearance. See Admin.
Next
- Admin and Storefront: where each target appears.
- Styling: CSS for workers.