# Build your first Stackable extension with Claude Code

> Install one plugin, scaffold a project, and pair with Claude to ship an order-status card that lives next to your Zendesk messenger.

By [Rob Bowers](https://www.linkedin.com/in/rob-bowers-72516422/), CTO - Stackable Labs · October 26, 2026

We built Stackable's developer tooling to be AI-native from the start. Not "we added a chatbot to the docs," but tooling an AI coding assistant can actually use: SDK knowledge packaged as Agent Skills, a hosted MCP server that can validate your manifest against the real platform, and a Claude Code plugin that wires both up in one install.

This post walks through building a small, useful extension with that setup: an **"order status" card** that shows a customer's latest order next to the Zendesk messenger, plus a button that shares the status into the conversation. It's the same kind of surface that gives AI agents that act somewhere to do it.

## What you'll need

- **Node.js 22** or later and **pnpm**
- **Claude Code**
- A Stackable account at [admin.stackablelabs.com](https://admin.stackablelabs.com)
- An API that can return an order for a customer. We'll use `api.example.com` as a stand-in for yours.

## 1. Install the Claude Code plugin

From Claude Code:

```bash
/plugin marketplace add stackable-labs/claude-plugins
/plugin install stackable-extension-dev@stackable-claude-plugins
```

The plugin bundles two things:

- **Stackable Agent Skills.** These are the SDK concepts (surfaces, capabilities, components, hooks, permissions, guardrails) published as skills Claude can pull in when it needs them, instead of guessing from training data or crawling docs.
- **The Stackable MCP server**, pre-configured. It's hosted at `https://mcp.stackablelabs.io/app-extension`, authenticates with OAuth on first use, and exposes tools like `list_apps`, `list_extensions`, `get_extension`, `validate_manifest`, and `validate_permissions`.

Not on Claude Code? The same Skills install with `pnpm dlx skills add stackable-labs/skills`, and the MCP server works with any MCP-compatible client, including Cursor, Windsurf, VS Code, Codex, and Claude Desktop.

## 2. Scaffold the project

```bash
pnpm --config.dlx-cache-max-age=0 dlx @stackable-labs/cli-app-extension@latest create order-status
cd order-status
```

`create` walks you through a short wizard. The first time you run it, it opens your browser so you can sign in to Stackable. You don't need a separate `auth login` step. It then asks which App the extension is for (chosen automatically if you have only one; pass `--app-id` to skip), which template to use, which slots to target (pick `slot.content` for this tutorial), and which local ports to use. Then it registers the extension in your App, downloads the template, installs dependencies, and makes an initial git commit.

The Starter template (the default) gives you one surface for each slot you pick, plus a shared store, API helpers, and a local preview host. (Use `--template minimal` for a bare hello-world, or `--template kitchen-sink` to see every component, capability, surface, and hook in one place.)

Using an assistant other than Claude Code? Add the Stackable Skills to your project with `pnpm dlx skills add stackable-labs/skills`, or run `pnpm --config.dlx-cache-max-age=0 dlx @stackable-labs/cli-app-extension@latest ai scaffold` to write the editor configs (`.claude/`, `.cursor/`, and so on) into the project.

The parts you'll touch most:

```text
order-status/
  packages/
    extension/
      public/manifest.json   # targets, permissions, allowed domains, settings
      src/index.tsx          # createExtension bootstraps the runtime
      src/surfaces/          # one component per surface slot
      src/lib/               # API wrappers
    preview/                 # local preview host (don't modify)
```

## 3. Preview it live

Start locally:

```bash
pnpm dev
```

That runs your extension's dev server (http://localhost:6543) and a local preview host (http://localhost:6544) that loads it in a sandbox with mock customer data and hot reload. The preview calls `data.fetch` straight from the browser, so secret placeholders aren't filled in, and the Share button stays disabled because there's no live conversation.

To test those, install the extension on one of your sites, then run:

```bash
pnpm preview
```

This starts the dev servers, opens Cloudflare tunnels, and prints a `_stackable_dev` query parameter. Append it to the URL of a site where your extension is installed, and that browser session loads your local code instead of the production bundle. Nothing changes in any database, and each developer's override is theirs alone.

## 4. Ask Claude for the feature

Here's roughly the prompt we'd start with:

```text
In slot.content, show the customer's most recent order: order number,
a status badge, and the expected delivery date. Look it up with
data.fetch from https://api.example.com/orders/latest?email=<customer email>,
using an API key that installers enter as a secret setting.
Add a "Share in chat" button that posts the status into the conversation.
Validate the manifest when you're done.
```

With the plugin installed, Claude has what it needs to get the details right: which permission each capability requires, which domains need allowlisting, how secret settings work, which components and attributes exist, and what the sandbox forbids. When it's finished, it can call `validate_manifest` on the MCP server instead of telling you it *thinks* the manifest is right.

Your result will vary a little from run to run. It should end up close to what follows.

## 5. The manifest

```json
{
  "name": "Order Status",
  "version": "1.0.0",
  "targets": ["slot.content"],
  "permissions": ["context:read", "data:fetch", "messaging:send"],
  "allowedDomains": ["api.example.com"],
  "settingsSchema": [
    {
      "identifier": "apiKey",
      "label": "API Key",
      "type": "text",
      "secret": true,
      "required": true,
      "description": "Stored encrypted. Injected server-side into outbound requests."
    }
  ]
}
```

Every line here does a job:

- **`targets`** declares where the extension renders.
- **`permissions`** is the full list of what it can do. The host grants these and nothing else.
- **`allowedDomains`** limits where `data.fetch` can go. Requests anywhere else are rejected.
- **`settingsSchema`** becomes a form in the admin dashboard when someone installs your extension. Because `apiKey` is marked `secret`, it's encrypted with a per-install key and never sent to the browser.

## 6. The surface

An example below of what Claude helped me create:

```tsx
import { useEffect, useState } from 'react'
import { Surface, ui, useCapabilities, useContextData, useMessaging } from '@stackable-labs/sdk-extension-react'

type Order = { id: string; status: string; eta: string }

export const Content = () => {
  const { fetch: fetchData } = useCapabilities().data
  const { loading, customerEmail } = useContextData()
  const [send, { enabled, loading: sending }] = useMessaging()
  const [order, setOrder] = useState<Order | null>()

  useEffect(() => {
    if (loading) {
      return
    }

    if (customerEmail == undefined || customerEmail === '') {
      setOrder(null)
      return
    }

    fetchData(`https://api.example.com/orders/latest?email=${encodeURIComponent(customerEmail)}`, {
      headers: { 'X-Api-Key': '{{settings.apiKey}}' },
    })
      .then((result) => setOrder(result.ok ? (result.data as Order) : null))
      .catch(() => setOrder(null))
  }, [fetchData, loading, customerEmail])

  const shareStatus = async () => {
    if (order == undefined) {
      return
    }

    try {
      await send({ kind: 'text', body: `Order ${order.id} is ${order.status}. Expected ${order.eta}.` })
    } catch {
      // invalid_message, rate_limited, or upstream_error
    }
  }

  if (loading || order === undefined) {
    return (
      <Surface id="slot.content">
        <ui.Skeleton height="96px" />
      </Surface>
    )
  }

  if (order === null) {
    return (
      <Surface id="slot.content">
        <ui.Text tone="muted">No recent orders found.</ui.Text>
      </Surface>
    )
  }

  return (
    <Surface id="slot.content">
      <ui.Card>
        <ui.CardHeader>
          <ui.Heading level="3">Order {order.id}</ui.Heading>
        </ui.CardHeader>
        <ui.CardContent>
          <ui.Stack direction="column" gap="2">
            <ui.Badge hue="blue">{order.status}</ui.Badge>
            <ui.Text>Expected {order.eta}</ui.Text>
            <ui.Button variant="default" disabled={!enabled || sending} onClick={shareStatus}>
              Share in chat
            </ui.Button>
          </ui.Stack>
        </ui.CardContent>
      </ui.Card>
    </Surface>
  )
}
```

A few details worth pointing out:

- **The secret never shows up in your code.** `'{{settings.apiKey}}'` is a placeholder. The Stackable proxy swaps in the real value server-side for that one request, then discards it. Placeholders only work in header values, which is deliberate.
- **`enabled` comes from `useMessaging()`.** It's `true` only when there's an active conversation, so the button can't fire into nothing, and you don't need an extra permission to check.
- **`fetchData` is the dependency, not the whole capabilities object.** `useCapabilities()` returns a fresh object on every render, but `data.fetch` itself is stable, so depending on it keeps the effect from refetching on every render.
- **No CSS.** The `ui.*` components inherit the host's theme, including dark mode. Your card looks like it belongs to the brand that installed it.
- **The author label isn't yours to set.** Messages from `send()` appear under a name the brand's admin sets for their install, falling back to your extension's name. That keeps one extension from posing as another.

## 7. Validate, build, ship

**Validate.** Ask Claude to run `validate_manifest` and `validate_permissions`, or walk through the plugin's validate checklist. When you submit, the platform runs its own automated bundle scan: do your declared permissions match the capabilities your code uses, do your targets match the surfaces you register, do your `allowedDomains` match where `data.fetch` actually goes, and are you staying away from forbidden browser globals? Catching those issues locally is a lot faster.

**Build and host.** `pnpm build` writes a static bundle to `packages/extension/dist/`. Host it anywhere that serves HTTPS with permissive CORS. Netlify, Vercel, Cloudflare Pages, and S3 with CloudFront all work. Then point your extension's Bundle URL at the hosted `extension.js` (keep `manifest.json` next to it), either in the admin dashboard or from the CLI:

```bash
pnpm --config.dlx-cache-max-age=0 dlx @stackable-labs/cli-app-extension@latest update --bundle-url https://your-host.example.com/extension.js
```

`update` is also how manifest changes reach the registry: it walks you through syncing your local permissions, `allowedDomains`, and `settingsSchema`, so installers see the API Key field.

**List it.** In the admin dashboard, add an icon, screenshots, and a description, choose **Public** or **Protected** visibility, and submit for review. After the automated scan, there's an advanced review and then Stackable approval. Most reviews finish within a few business days. Once you're published, redeploying to the same Bundle URL updates every install automatically.

## Prefer the browser?

Not a terminal person? Fair. Everything above also works in the browser. Stackable's **AI Extension Studio** in the admin dashboard has a component shelf, a live preview that uses the same rendering pipeline as production, and Sidekick, an assistant that writes code, updates your manifest, and looks up the SDK as you go.

When a Studio project outgrows a single file (or you now have code you'd rather hand to a developer than read yourself), pull it down as a full local project:

```bash
pnpm --config.dlx-cache-max-age=0 dlx @stackable-labs/cli-app-extension@latest scaffold --project-id <projectId>
```

Start in whichever place feels natural. Both paths produce the same quality kind of extension - production ready to ship to the marketplace.

## Where to go next

The [developer docs](https://developers.stackablelabs.com) cover every capability in this post and more: identity enrichment, activity events, carousels, and the full component catalog. When you're ready, [create your account](https://admin.stackablelabs.com) and ship something. We'd love to see what you build.
