The browser half of a dynamic package

A dynamic package is defined and run inside a single chat, and when it shows any interface it ships a browser half that loads in the app's page. This page covers writing that half: the names it can use, where its interface can go and what its failures mean. Running and managing a package is on Dynamic packages, and the other side of the pair is on The host half of a dynamic package.

A package that shows no interface needs no browser half: it runs host-only, with its whole surface in the chat.

Write it in plain JavaScript

A browser half is plain JavaScript, evaluated in the page as the body of an async function. There is no TypeScript, no JSX and no module imports, and the app hands the code a fixed set of names:

The browser features you might reach for do not work here; each one throws a redirect that names the correct route instead:

Return your plugin from the half. A plain function is accepted, but only the object form can declare what the half uses, and a plain function has no declaration site:

js
return {
  inject: ['slots'],
  apply(ctx) {
    // ...
  },
};

Declare what the half uses

Everything beyond those names goes through the services your plugin declares. Return an object with an apply(ctx) method and list the services in inject. Two habits cover most cases: declare a service in inject when the half depends on it, and use ctx.get(name) with an undefined check when it can live without it. A package that declares a service the browser page does not provide does not fail: it waits, and the load answer reports what it waits for.

The ctx inside apply is a facade, not the app's own context. It is read-only (dynamic ctx is read-only), it exposes the lifecycle verbs (ctx.on, ctx.provide), the timer helpers once you declare timer, and only the services you declared; framework internals are withheld by design. A service you did not declare is refused with is not declared by your plugin; declare it on the plugin you return, and the runtime then parks the package if the provider unloads. A service that returns a context object is refused too: the facade does not expose other contexts, so work through your own plugin context and the services you declared.

The client services exposed to packages include slots, theme, timer, layout, locale, sessions, uiWorkspace and workspaces; query one with the inspection tools before you use it.

Important

The facade keeps a browser half to a supported API, but it is not a security boundary. Run only packages you trust.

Put your interface in a slot

The app's interface is built from slots: named places a package can render its own components into. The slot catalog lists 110 of them, across the conversation, the sidebar, Settings and the plugin areas, the shared-conversation viewers, tool cards, and the main, root, rightbar, deliverables and shell areas. Every slot has a kind (single, list, keyed or chain) and a scope (root, session or session-maybe), and about half of the slots replace shipped UI when a package claims them while the rest are additive.

Adding your UI takes three steps:

  1. Declare the slot service on the plugin you return: inject: ['slots']. Without the declaration the slot is withheld.
  2. Wrap each registration in ctx.slots.inject(key, () => ...). Registering into a slot whose owner is not mounted throws, and the wrapper runs your registration again when the owner remounts.
  3. Register the component with ctx.slots.register(options, component). The options carry a name; one slot renders a package's own view inside the card of its latest run and accepts only the key self, which the runtime binds to your package.

A minimal entry looks like this:

js
return {
  inject: ['slots'],
  apply(ctx) {
    ctx.slots.inject('<slot key>', () =>
      ctx.slots.register({ name: 'my-entry' }, MyEntry),
    );
  },
};

function MyEntry() {
  return React.createElement('div', null, 'Hello from my package');
}

More rules shape the result:

Read the catalog before you pick a key: it lists what each register call must pass, what the component receives, who already occupies the slot and which plugin must be mounted for it to exist. The inspection tools serve it; see Inspect and manage extensions. Never guess a key.

Style it

CSS goes in through styles.insert(css): it takes a CSS string, adds stylesheet tags owned by your package, and removes every tag it added when the package unloads. Base the look on the theme variables rather than literal colours: var(--dsw-alias-bg-layer-1), var(--dsw-alias-label-primary).

To give the package its own colours, declare theme and call ctx.theme.overrideTokens with two arguments. The first is any string; the facade replaces it with your package id, so the override is attributed to your package. The second is a token map with light and dark values:

ctx.theme.overrideTokens('mine', { '--dsw-alias-bg-layer-1': { light: '#ffffff', dark: '#111111' } })

An override is removed when the package unloads, so the shipped values come back.

Call the host half

The host half is where the package's work with files, network and long-running steps happens. The browser half calls it with host.call(method, args):

js
const rows = await host.call('load-rows', { limit: 10 });

Both directions carry JSON only. An omitted argument arrives at the handler as null; an answer is JSON too, and null means the handler has nothing to report. A payload that cannot cross is refused with the JSON rule spelled out.

When the call cannot reach its target, the refusal says which of four things happened:

The other side of the call is on The host half of a dynamic package: that is where harness.handle(method, fn) registers the handler.

Move the app from a browser half

A half can also change what the app is showing. Declare the service and call it the same way:

DeclareExample calls
layouttoggleSidebar, openRightbar, closeRightbar, selectPanel
uiWorkspaceopenSession, openWorkspace, startSession, forkSession, archiveSession, unarchiveSession, pickDirectory, listDirectory, createDirectory
workspacescreate, rename, delete, insertSessionBefore

Query the exact signatures with the inspection tools before you rely on one.

When a load or a render fails

A browser half can fail while its code is evaluated (evaluate), while its module entry is imported (module-import), or while it starts (activate); the load answer names the stage. A module import failure points you at the browser console: module import failed (see the browser console). A parse failure says it failed to parse in this browser and repeats the rule: The browser half is plain JavaScript (no JSX, no TypeScript); build elements with React.createElement. A successful load is not proof that the interface rendered: React renders after the load receipt, and any render failure arrives separately.

A component that crashes while React renders it is reported against your package and its slot: your entry in slot "<slot>" crashed while React rendered it: <message>. A crash inside a factory reads your component in Factory "<name>" crashed while React rendered it: <message>. If the crash message names a withheld global, the report adds that global's redirect. Only the latest crash per package is kept, and it clears when the package stops or is reloaded.

Runs converge rather than stack: asking for the same revision a page already runs changes nothing, and a newer revision replaces the loaded one. A page reload starts clean, so the package stays defined and its browser half loads again only when the package is run again.

When the package stops, is removed or is replaced, everything the half added goes with it: slot registrations are removed, inserted styles are cleared, and theme overrides are restored.

Questions, answered

Can I write the browser half in TypeScript or JSX?

No. It is plain JavaScript: no TypeScript, no JSX and no module imports. React arrives as the React symbol, and elements are built with React.createElement.

How do I use timers in the browser half?

Declare inject: ['timer'] and use the timer helpers on your plugin context. The browser's own timer globals throw the redirect browser timer globals are unavailable in dynamic packages. In React, create timers from an event handler or React.useEffect, and return callback-form disposers from the effect cleanup.

How do I call the network from the browser half?

Through the host half. fetch is unavailable here; the host half registers a handler with harness.handle(method, fn) and the browser half calls it with host.call(method, args).

Where can my interface appear?

In any slot: the catalog lists 110 keys across the conversation, the sidebar, Settings and the plugin areas, the shared-conversation viewers, tool cards, and the main, root, rightbar, deliverables and shell areas. Read the catalog with the inspection tools before you pick one; it says what each register call must pass and who already occupies the slot (Inspect and manage extensions).

Why was my slot registration refused?

It hit one of the slot's rules: the options need a name; the package's own view inside its run card accepts only the key self, which the runtime binds to your package; or the slot's owner is not mounted, which ctx.slots.inject(key, () => ...) handles by registering when it mounts. The refusal names the rule.

Will my entry replace Oda's own interface?

In some slots, yes. About half of the slots replace the shipped UI when a package claims them, and the rest are additive; in a single or keyed slot your entry is the one that renders, because the facade assigns a priority lower than every shipped entry. You never pass a priority yourself.

What happens to my interface when the package stops or is replaced?

Everything the half added is cleaned up with it: slot registrations are removed, inserted styles are cleared, and theme overrides are restored. A newer revision replaces the loaded one when the package is run again.

Why did my browser half fail to load?

The load answer names the stage that failed: evaluate, module-import or activate. A parse failure comes with the fix (plain JavaScript, build elements with React.createElement); a module import failure points you at the browser console.

What happens when my component crashes while rendering?

The crash is reported against your package and its slot, and only the latest crash per package is kept. It clears when the package stops or is reloaded. If the crash message names a withheld global, the report adds that global's redirect.

Why does my package not appear again after a page reload?

A page reload starts clean: the package stays defined, but its browser half loads again only when the package is run again.