extension-points

$npx mdskill add BuilderIO/agent-native/extension-points

Embed custom widgets into app UI slots by ID.

  • Users request custom widgets for specific app areas.
  • The framework matches tools to slots via string IDs.
  • Agents query user installs to populate named holes.
  • Widgets render as iframes pushing context via postMessage.

SKILL.md

.github/skills/extension-pointsView on GitHub ↗
---
name: extension-points
description: >-
  How tools render as widgets inside other apps via named UI slots — the
  framework's VS-Code-style extension system. Use when a user asks to add a
  custom widget to an app surface (e.g. "add a sticky-note widget to my mail
  contact sidebar"), when wiring an ExtensionSlot in a template, or when
  marking a tool as installable into a slot.
---

# Extension Points

## Mental model

**Slots** are named React-shaped holes in apps. **Tools** are widgets that opt
into filling those holes. The framework matches them up by string ID.

Three primitives:

| Primitive            | What it is                                                                        |
| -------------------- | --------------------------------------------------------------------------------- |
| **Slot**             | `<ExtensionSlot id="..." context={...} />` dropped into an app's JSX             |
| **Slot target**      | A row saying "tool X can render in slot Y" — `tool_slots` table                  |
| **Slot install**     | A row saying "user U wants tool X in slot Y" — `tool_slot_installs` table        |

When `<ExtensionSlot>` renders, it queries the user's installs and mounts one
`<EmbeddedTool>` (a small auto-sized iframe) per install, pushing the slot's
context into each via postMessage.

## Slot ID convention

`<app>.<area>.<position>` — three dot-separated lowercase-kebab segments.

- `mail.contact-sidebar.bottom`
- `mail.thread-toolbar.actions`
- `clips.right-panel.tabs`
- `calendar.event-detail.bottom`

Stable strings. Renaming a slot is a data migration — same as renaming a
route.

## How to author a tool that fills a slot

1. **Create the tool** with `create-tool`. The HTML can read `window.slotContext`
   to get the host's context (the contact email, recording id, etc.) and
   subscribe to changes via `window.onSlotContext(fn)`.

   ```html
   <div
     x-data="{ contact: null }"
     x-init="contact = window.slotContext; window.onSlotContext(c => contact = c)"
   >
     <template x-if="contact">
       <div class="rounded-lg border p-4 m-4">
         <p class="text-sm">
           Notes for <span x-text="contact.contactEmail"></span>
         </p>
       </div>
     </template>
   </div>
   ```

2. **Declare the slot target** with `add-tool-slot-target`:

   ```
   add-tool-slot-target { toolId: "<id>", slotId: "mail.contact-sidebar.bottom" }
   ```

3. **Install it** for the current user with `install-extension`:

   ```
   install-extension { toolId: "<id>", slotId: "mail.contact-sidebar.bottom" }
   ```

The slot will pick up the install on its next render (≤2s via polling sync,
immediate after the action's UI invalidation).

## How to declare a slot in your app

Drop `<ExtensionSlot>` wherever you want to allow extensions:

```tsx
import { ExtensionSlot } from "@agent-native/core/client/tools";

// inside your component
<ExtensionSlot
  id="mail.contact-sidebar.bottom"
  context={{ contactEmail: contact.email, contactName: contact.name }}
  showEmptyAffordance
/>;
```

Props:

- `id` — slot identifier. Must match what tools target.
- `context` — object pushed to each embedded tool as `slotContext`. Re-pushed
  whenever this prop changes.
- `showEmptyAffordance` — when true, shows a "+ Add widget" button in the
  empty state. Default: false (slot renders nothing when empty).
- `className` / `toolClassName` — optional styling hooks.

The host doesn't register slots in advance — `<ExtensionSlot>` is the
declaration. If a tool targets a slot ID that no app has placed, it just
won't render anywhere (the install record is harmless).

## Context contract

Each slot publishes whatever shape it wants via the `context` prop. There's
no schema enforcement in v1 — tools should null-check fields and fail
gracefully if a field they expect is missing.

Document the context shape next to your `<ExtensionSlot>` so tool authors know
what to read. Convention: include the document in the slot ID's prefix
section so the agent can find it (`mail.contact-sidebar.*` slots all publish
`{ contactEmail, contactName }`).

## Agent actions

| Action                  | What it does                                                  |
| ----------------------- | ------------------------------------------------------------- |
| `add-tool-slot-target`  | Mark a tool as installable into a slot (tool author opts in)  |
| `install-extension`     | Install a tool into a slot for the current user               |
| `uninstall-extension`   | Remove a tool from a slot for the current user                |
| `list-tools-for-slot`   | List installable tools for a given slot ID                    |
| `list-tool-slots`       | List slot targets a tool declares                             |

Typical flow when a user asks "add a CRM widget below my contacts":

1. `list-tools-for-slot { slotId: "mail.contact-sidebar.bottom" }` — see
   what's already installable
2. If a fitting tool exists: `install-extension`
3. Otherwise: `create-tool` → `add-tool-slot-target` → `install-extension`

## Lifecycle

**Mount** — host calls the slot installs API, renders an `<iframe>` per
install. The iframe URL includes `?slot=<slotId>` so the runtime knows it's
embedded (enables auto-resize, suppresses anything that only makes sense
full-page).

**Context push** — host posts `agent-native-slot-context` immediately on
iframe load, and again on every prop change. The tool reads the current value
synchronously via `window.slotContext` and subscribes via
`window.onSlotContext(fn)` for live updates.

**Auto-resize** — when in slot mode, the iframe runtime measures its content
height and posts `agent-native-tool-resize` to the host. The `<EmbeddedTool>`
sets the iframe height accordingly. Use `ResizeObserver` to follow content
changes.

**Tool API** — embedded tools have the full helper set: `appAction`,
`appFetch`, `dbQuery`, `dbExec`, `toolFetch`, `toolData`. Same auth context as
full-page tools.

**Unmount** — uninstall deletes the install row. Polling sync invalidates
the `slot-installs` query and the host re-renders without the iframe.

## Permissions

- Installing requires viewer access to the tool. A user can only install
  tools they have access to.
- Declaring slot targets requires editor access to the tool.
- Slot installs are per-user — installing a widget only affects the
  installing user's view. There's no org-wide "default install" in v1.
- Slots themselves are ungated. Any app code can drop an `<ExtensionSlot>`
  in any user's view; the slot's contents come from that user's installs.

## What this is NOT

- **Not a way to render arbitrary React in slots.** Slots only render
  Alpine.js iframe tools. Same security/auth/sandbox as `/tools/:id`.
- **Not cross-tool messaging.** Two tools in the same slot can't read each
  other's `toolData`. Use actions or app SQL if widgets need to coordinate.
- **Not a slot manifest.** Slot targets live in the `tool_slots` table, not
  in the tool's HTML content. The agent can re-target a tool without
  rewriting it.
- **Not for arbitrary code modification.** If a user wants to change how
  the app itself behaves (not add a sandboxed widget), use the
  `self-modifying-code` skill instead.

## Cross-references

- `tools` skill — authoring Alpine.js mini-apps (the substrate for widgets)
- `sharing` skill — how access flows from tool sharing to slot installs
- `context-awareness` skill — how tools read what the user is looking at
- `actions` skill — how `install-extension` etc. are auto-mounted

More from BuilderIO/agent-native