# Extension Points

> How extensions render as widgets inside other apps via named UI slots — the framework's optional extension system. Use when an extension-enabled app needs to wire an ExtensionSlot or make an existing one-off custom block installable into a slot.

- Skill: `builderio/extension-points` (Agent Skill)
- Install (CLI): `npx skillmds@latest add builderio/extension-points`
- Raw SKILL.md: https://api.skillmd.com/api/skills/builderio/extension-points/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Builder.io (https://skillmd.com/u/builderio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/builderio/extension-points

---


# Extension Points

> **Terminology note.** "Extensions" in this doc are the framework's
> sandboxed Alpine.js mini-app primitive (see the `extensions` skill).
> They are NOT LLM "tools" (function calls). The slot-system tables are
> still physically named `tool_slots` and `tool_slot_installs` for
> back-compat — see the table at the bottom of this doc and the
> "Database & API names" section in the `extensions` skill.

## Mental model

**Slots** are named React-shaped holes in apps. **Extensions** 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 "extension X can render in slot Y" — `tool_slots` table (Drizzle: `extensionSlots`) |
| **Slot install**     | A row saying "user U wants extension X in slot Y" — `tool_slot_installs` (Drizzle: `extensionSlotInstalls`) |

Slots do not make extensions a default product surface. Most apps keep
extension creation and discovery disabled while preserving installed blocks and
old deep links for compatibility. Add a slot only when the app deliberately
supports this customization seam; otherwise implement the requested behavior in
native app code.

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. (The component is still exported
as `EmbeddedTool` for back-compat.)

## 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 an extension that fills a slot

Only follow this flow when the host app explicitly enables extensions and the
user has asked for a one-off custom block. Reusable behavior belongs in app
code.

1. **Create the extension** with `create-extension`. 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-extension-slot-target`:

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

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

   ```
   install-extension { extensionId: "<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/extensions";

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

> The legacy import path `@agent-native/core/client/tools` continues to
> re-export the same component for back-compat with existing templates.

Props:

- `id` — slot identifier. Must match what extensions target.
- `context` — object pushed to each embedded extension 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 `toolClassName`
  prop name is kept for back-compat; it styles the embedded extension's
  iframe wrapper.)

The host doesn't register slots in advance — `<ExtensionSlot>` is the
declaration. If an extension 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 — extensions should null-check fields and fail
gracefully if a field they expect is missing.

Document the context shape next to your `<ExtensionSlot>` so extension
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-extension-slot-target`  | Mark an extension as installable into a slot (extension author opts in) |
| `install-extension`          | Install an extension into a slot for the current user                   |
| `uninstall-extension`        | Remove an extension from a slot for the current user                    |
| `list-extensions-for-slot`   | List installable extensions for a given slot ID                         |
| `list-extension-slots`       | List slot targets an extension declares                                 |

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

1. `list-extensions-for-slot { slotId: "mail.contact-sidebar.bottom" }` —
   see what's already installable
2. If a fitting extension exists: `install-extension`
3. Otherwise: `create-extension` → `add-extension-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 extension 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` (postMessage type kept
for back-compat) to the host. The `<EmbeddedTool>` sets the iframe height
accordingly. Use `ResizeObserver` to follow content changes.

**Extension API** — embedded extensions have the full helper set:
`appAction`, `appFetch`, `dbQuery`, `dbExec`, `extensionFetch`,
`extensionData` (with `toolFetch` / `toolData` legacy aliases). Same auth
context as full-page extensions.

**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 extension. A user can only
  install extensions they have access to.
- Declaring slot targets requires editor access to the extension.
- 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 extensions. Same security/auth/sandbox as
  `/extensions/:id`.
- **Not cross-extension messaging.** Two extensions in the same slot can't
  read each other's `extensionData`. Use actions or app SQL if widgets need
  to coordinate.
- **Not a slot manifest.** Slot targets live in the `tool_slots` table
  (Drizzle export `extensionSlots`), not in the extension's HTML content.
  The agent can re-target an extension without rewriting it.
- **Not for arbitrary code modification.** If the exact request needs UI inside
  a native component or at a location with no slot, do not stop at "slots
  cannot do that" or silently move it elsewhere. Treat it as full app
  customization: use `connect-builder` for the Builder.io Cloud Agent/local
  editing handoff in hosted chat, or follow `self-modifying-code` in a local
  code-editing surface.

## Cross-references

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

