Blog · October 26, 2026
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, CTO - Stackable Labs
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
- An API that can return an order for a customer. We'll use
api.example.comas a stand-in for yours.
1. Install the Claude Code plugin
From Claude Code:
/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 likelist_apps,list_extensions,get_extension,validate_manifest, andvalidate_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
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:
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:
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:
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:
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
{
"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:
targetsdeclares where the extension renders.permissionsis the full list of what it can do. The host grants these and nothing else.allowedDomainslimits wheredata.fetchcan go. Requests anywhere else are rejected.settingsSchemabecomes a form in the admin dashboard when someone installs your extension. BecauseapiKeyis markedsecret, 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:
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. enabledcomes fromuseMessaging(). It'strueonly when there's an active conversation, so the button can't fire into nothing, and you don't need an extra permission to check.fetchDatais the dependency, not the whole capabilities object.useCapabilities()returns a fresh object on every render, butdata.fetchitself 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:
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:
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 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 and ship something. We'd love to see what you build.