Sign In

Plugin SDK

A plugin is a package with a server entry (definePlugin) and an optional client entry (definePluginClient). Workspaces install it from the store (a priced plugin is bought once per workspace); it can answer conversations, give the AI agent tools, sell its own items, serve public pages and webhooks, and add pages, panels, sidebar groups and inbox buttons. What it does is read from the definition: there is no capability list to keep in sync.

PartWhat it does
actionsThe only way in: called from the dashboard, other plugins, the AI (tool) and the public API (api). Input is checked against its schema; members need its permission.
inboundRead every inbound message; claim the conversation, reply, keep state, then release it.
onOne event bus: Beamvia's events, schedules (schedules) and other plugins' events; runs in the background.
routesPublic pages (as blocks) and webhooks with the raw body, under /public/<workspace>/<plugin>/…
collectionsStorage with indexes, unique keys, text search and encrypted fields; atomic claim and counters. Fields in contact are deleted with the contact.
billingItems the plugin sells on top (credits, packs); prices, and the one-time workspace license, are set in the store.
aiInstructions and live per-conversation context added to the agent when its tools are on.
uiNavigation (own sidebar group, Plugins group or settings tab), pages and panels.
Layout
my-plugin/
├── package.json        "build": "bun build … (see Publishing)"
├── src/
│   ├── index.ts        definePlugin()        — runs on the Beamvia server (sandboxed)
│   └── client.tsx      definePluginClient()  — React pages and dashboard slots (optional)
└── listing/            the public store page (optional, see Store page)
    ├── listing.json    TPluginListing: tagline, description, highlights, FAQ, setup, SEO (en + id)
    └── media/          cover (1200×630) and screenshots (1600×1000): PNG, JPEG, WebP or SVG

Server entry

definePlugin() describes everything that runs on the Beamvia server, in an isolated process. It never touches the database or the network directly: it gets a ctx scoped to the installing workspace.

Inbound result

fieldWhat it does
claimTake the conversation; nothing else answers until you release it.
releaseGive the conversation back to the channel's default handler.
stateSaved on the conversation and passed to your next onMessage while you own it.
repliesMessages sent in order (text, media, location, contact, PLUGIN cards).
stopEnd the pipeline for this message without claiming (no auto reply, no AI).

Events

eventWhat it does
plugin.installedInstalled (or installed again within the retention).
plugin.upgradedThe workspace moved to another build ({ from, to, isRollback }).
plugin.billing.updatedAn item was bought.
inbox.message.createdA message in any conversation (never your own).
inbox.conversation.updatedStatus, handler or tags changed.
contact.updatedA contact changed.
contact.mergedTwo contacts became one: move what you keep to primaryId.
contact.deletedRemove or anonymise what you keep for the contact.
schedule.<name>Your cron (schedules) is due, in the workspace timezone.
<pluginKey>:<name>An event another plugin emitted (also delivered to webhooks subscribed to plugin:<key>:<name>).

Context

Every entry point receives ctx, scoped to the workspace that installed the plugin. The same methods work in tests and in the sandbox.

ctxWhat it does
ctx.configSettings values (SECRET fields decrypted, server only).
ctx.storageinsert, insertMany, find (sort, skip, limit, search), findOne, count, group, update, claim, upsert, remove, next (counters).
ctx.inboxSend messages and cards, write private notes, add tags, change status, open a conversation with a contact.
ctx.contactsRead, update, resolve by phone or email, add identities and memories.
ctx.templateRender a template with the conversation's variables and your own data.
ctx.pluginsCall another installed plugin's action.
ctx.eventsEmit "<pluginKey>:<name>" to listening plugins and webhooks.
ctx.billingItem balances; consume purchased items.
ctx.notificationBell notifications for members with a permission.
ctx.httpHTTPS to the hosts in manifest.network (10 s, 2 MB, no private addresses).
ctx.filesStore a file in the workspace drive (counted in its storage), or read one's text. Its url is a stable link that works until the file is deleted.
ctx.emailA plain email from the platform address.
ctx.realtimeLive updates to your open pages (useRealtime).
ctx.uiBadge counts on navigation items.
ctx.urlAbsolute links to your public routes and dashboard pages.
ctx.aiThe workspace's AI agents and which of your tools each has on.
ctx.loggerServer logs that also appear in the install's activity.
src/index.ts
import { definePlugin } from "@beamvia/plugin-sdk";

type TConfig = { greeting: string };

export default definePlugin<TConfig>({
  manifest: { key: "@acme/greeter", name: "Greeter", description: "Greets new customers", icon: "Hand", version: "1.0.0", author: "Acme", network: ["api.acme.com"] },
  config: [{ key: "greeting", label: { en: "Greeting", id: "Sapaan" }, type: "TEXT", default: "Hi <%= contact.name.first %>!" }],
  permissions: [{ key: "plugin.greeter.manage", label: "Reset greeter stats" }],
  billing: { items: { greetings: { name: { en: "Greeting pack", id: "Paket sapaan" } } } },
  // contact: rows are deleted with the contact they belong to
  collections: { greeted: { indexes: [["contactId"]], unique: [["contactId"]], contact: ["contactId"] } },

  // ** Claim a conversation, answer, then give it back
  inbound: {
    priority: 50,
    onMessage: async (ctx, { contact, isFirstMessage, isOwner }) => {
      if (!isFirstMessage || isOwner) return;
      await ctx.storage.upsert("greeted", { contactId: contact.id }, { $inc: { count: 1 } });
      return { replies: [{ type: "TEXT", text: await ctx.template.render(ctx.config.greeting, { data: {} }) }] };
    },
  },

  // ** The only RPC: dashboard, other plugins, the AI (tool) and the API (api)
  actions: {
    stats: async (ctx) => ({ count: await ctx.storage.count("greeted") }),
    reset: { permission: "plugin.greeter.manage", run: async (ctx) => ({ removed: await ctx.storage.remove("greeted", {}) }) },
    greeted_count: {
      input: { type: "object", additionalProperties: false, required: [], properties: {} },
      tool: { description: "How many customers were greeted", risk: "LOW" },
      api: true,
      run: async (ctx) => ({ count: await ctx.storage.count("greeted") }),
    },
  },

  // ** One event bus: Beamvia's events, schedules and other plugins' events (background)
  on: {
    "inbox.message.created": async (ctx, { conversation, message }) => {
      if (message.direction === "INBOUND" && /refund/i.test(message.text)) await ctx.inbox.addTag(conversation.id, "refund");
    },
    "schedule.daily": async (ctx) => ctx.logger.info("Daily tick"),
  },
  schedules: { daily: "0 9 * * *" },
  emits: { greeted: { description: "A customer was greeted" } },

  // ** Public pages (blocks) and webhooks: /public/<workspace>/greeter/…
  routes: {
    "GET hello/:name": async (ctx, request) => ({ page: { title: "Hello", blocks: [{ type: "text", text: `Hi ${request.params.name}` }] } }),
    "POST webhook": async (ctx, request) => ({ status: 200, json: { received: request.body.length } }),
  },

  ai: { instructions: "Greet warmly; never promise refunds.", context: async (ctx, { contact }) => `Greeted before: ${!!(await ctx.storage.findOne("greeted", { contactId: contact.id }))}` },

  ui: {
    navigation: [{ group: { key: "greeter", label: "Greeter", icon: "Hand" }, items: [{ label: "Stats", path: "stats", icon: "BarChart3" }] }],
    pages: [{ path: "stats", title: "Greeter" }],                       // drawn by the client entry
    panels: { contact: async (ctx, { contact }) => [{ type: "text", text: `Contact ${contact.name}` }] },
  },
});

Client entry

definePluginClient() adds React components written with normal JSX. Import the dashboard's shadcn components from @beamvia/plugin-sdk/ui and hooks from @beamvia/plugin-sdk/client; keep them, react and react/jsx-runtime external in the bundle so the dashboard provides them.

@beamvia/plugin-sdk/clientWhat it does
useQueryRead through an action, cached per input.
useActionRun an action once per click; refreshes your queries, toasts failures.
usePluginThe plugin, the workspace and can(permission).
useRealtimeListen to what ctx.realtime.publish sends.
useBillingItem balances and checkout.
useNavigateGo to another page of your plugin.
useCommandCenterInside a Command Center window or sidebar: open another of the plugin's windows, or a chat, as a Command Center window
toastSuccess and error toasts.

Slots

slotpropsWhat it does
conversation.panelconversationId, contactIdBelow the conversation details in the inbox.
contact.detailcontactIdOn the contact page, next to memory and identities.
inbox.composer.actionsconversationId, contactId, insertText, sendButtons next to the composer; insertText adds to the draft, send posts a message (e.g. a card) as the member.
inbox.message.actionsconversationId, contactId, messageItems in a message's menu (e.g. attach as payment proof).
inbox.message.renderconversationId, kind, data, directionDraws your PLUGIN messages in the inbox; channels get the fallback.
inbox.conversation.bannerconversationId, contactIdA strip under the chat header, next to the declared banner
src/client.tsx
import { definePluginClient } from "@beamvia/plugin-sdk";
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@beamvia/plugin-sdk/ui";
import { useAction, useQuery, toast } from "@beamvia/plugin-sdk/client";

const Stats = () => {
  const { data, isLoading } = useQuery<{ count: number }>("stats");
  const reset = useAction("reset");
  if (isLoading) return <Skeleton className="h-24" />;
  return (
    <Card>
      <CardHeader><CardTitle>Greeted customers</CardTitle></CardHeader>
      <CardContent className="flex items-center justify-between">
        <span className="text-2xl font-semibold">{data?.count}</span>
        <Button variant="outline" disabled={reset.isRunning} onClick={() => reset.run().then(() => toast.success("Reset"))}>Reset</Button>
      </CardContent>
    </Card>
  );
};

export default definePluginClient({
  key: "@acme/greeter",
  pages: { stats: Stats },
  slots: {
    "inbox.composer.actions": ({ insertText }) => <Button size="sm" variant="ghost" onClick={() => insertText("Hello! 👋")}>Wave</Button>,
  },
});

Command Center, metrics and commands

Plugins extend the inbox and the Command Center with declared parts: Beamvia draws them with its own components, so they work in every workspace without plugin code in the page. Client code (windows, sidebar) runs in an iframe of its workspace.

ui.…What it does
metricsNumbers and badges kept on contacts and conversations (ctx.contacts.metric, ctx.inbox.metric): pinned ones on inbox rows, all of them in the chat header, hover card and contact page, and in the AI context
commandsMenu items that run a plugin action on rows, selections, the chat header, the contact page, Command Center tabs and ⌘K
bannerA strip under the chat header, with an optional command button
hoverA section (blocks) in the Command Center contact hover card
commandCenter.sourcesA list of the plugin's own records, read straight from one of its collections
commandCenter.viewsSaved filters on a source, shown as tabs; records dragged between them change their writable fields
commandCenter.templatesStarting arrangements of the plugin's views
commandCenter.windowsWindows the client draws (definePluginClient commandCenter.windows), opened by sources, commands or useCommandCenter()
commandCenter.statusStatus bar counts, updated with ctx.ui.status(key, count)
RuleWhat it does
formatMetric formats: NUMBER, MONEY (with currency), TEXT, BADGE (with tone per value), DATE; 20 per plugin per record, strings up to 200 characters, setMany takes 500
targetCommand targets: inbox.row, inbox.selection, chat.header, contact.page, commandCenter.tab, palette. The action gets { target } and may answer { toast }, { open: { window, params } } or { navigate }
isolationA plugin only ever sees the workspace it is installed in; its windows and sidebar run in an iframe of that workspace
indexEvery field a source lets views filter on, and its item.at, must be in one of the collection's indexes, else the source is left out
permissionEach metric, command, source, window and status item may set a permission (default plugin.use); members without it never receive it
src/server.ts
// ** Metrics on every row, store views, a status count and a row command
ui: {
    metrics: {
        contact: {
            orders: { label: { en: "Orders", id: "Pesanan" }, format: "NUMBER", isPinned: true },
            spent: { label: { en: "Spent", id: "Belanja" }, format: "MONEY", currency: "IDR", isPinned: true },
        },
    },
    commands: [{ id: "ship", label: "Mark shipped", target: ["inbox.row", "inbox.selection"], action: "orderShip", when: { metric: "orders" } }],
    banner: async (ctx, { contact }) => ({ tone: "warning", text: "Unpaid order #1042", action: { label: "Remind", command: "remind" } }),
    commandCenter: {
        sources: {
            order: {
                collection: "orders",
                label: "Orders",
                item: { title: "number", subtitle: "customer.name", badge: "status", at: "updatedAt" },
                fields: [{ key: "status", label: "Status", type: "SELECT", isWritable: true, options: [{ value: "PAID", label: "Paid" }, { value: "SHIPPED", label: "Shipped" }] }],
                open: { window: "order" },
            },
        },
        views: [
            { id: "pending", name: "Pending shipment", source: "order", condition: [{ key: "status", op: "is", value: "PAID" }] },
            { id: "shipped", name: "Shipped", source: "order", condition: [{ key: "status", op: "is", value: "SHIPPED" }] },
        ],
        templates: [{ id: "ops", name: "Store ops", layout: { row: [{ view: "pending" }, { view: "shipped" }] } }],
        windows: { order: { title: "Order" } },
        status: { unpaid: { label: "Unpaid" } },
    },
},
collections: { orders: { indexes: [["status", "updatedAt"]] } },

// ** In a listener or schedule
await ctx.contacts.metric.set(contactId, { orders: 12, spent: 1250000 });
await ctx.ui.status("unpaid", 3);

// ** Client: the window the Command Center opens
definePluginClient({ commandCenter: { windows: { order: ({ params }) => <OrderDetail id={params.id} /> } } });

Permissions

Keys you declare under plugin.<name>.… (the part of your key after the scope, e.g. plugin.store.order.view for @acme/store) appear in the role editor under your plugin. An action's permission guards it for members; page and navigation permission hide them. Every key you require must be declared (or be a core key such as plugin.use); uploads that break either rule are refused. Add roles: ["AGENT"] to a key to grant it to that system role when a workspace installs the plugin. Tailwind classes in your client entry are built into client.css (dashboard theme) and load with it.

Navigation without a group goes to the shared Plugins group; group SETTING adds workspace settings tabs; a group object gives the plugin its own sidebar group (e.g. Store) with live badges.

Publishing a plugin

Build both entries into CommonJS bundles with source maps. In development point PLUGIN_DEV_PATH at your plugins folder: rebuilt bundles are released at once, without review. To publish, upload dist/package.zip (written by the build) on the developer page (/developer) or run bun run publish with a developer key; Beamvia reviews the code and you release the approved build (after a short cooldown, optionally to a share of installs). Builds that add permissions or a new maintainer wait for each workspace's consent. Within a major version keep storage backward compatible: add fields, never add a unique key on existing ones or drop a provided contract.

Build & publish
# package.json → "build": two CommonJS bundles with source maps
bun build src/index.ts   --outdir dist --entry-naming server.js --format cjs --target node    --sourcemap=external
bun build src/client.tsx --outdir dist --entry-naming client.js --format cjs --target browser --sourcemap=external \
  --external react --external react/jsx-runtime --external react/jsx-dev-runtime \
  --external @beamvia/plugin-sdk/ui --external @beamvia/plugin-sdk/client

# Development: Beamvia's PLUGIN_DEV_PATH=/path/to/your/plugins loads every <package>/dist/ bundle,
# lists it in the store and reloads it when you rebuild (no upload, no review; never in production)

# Publishing: upload dist/package.zip on /developer, or bun run publish (with listing/, the store page goes for review too)
#   manifest.key      must start with your developer scope, e.g. "@acme/greeter"
#   manifest.version  major.minor.patch, newer than the published one
# → an admin reviews the code (diff against the last version) → approves → publishes

Store page

Every listed plugin gets a public page at /plugins/<scope>/<name> that search engines and AI answer engines index: tagline, description (Markdown), highlights, gallery, setup steps, FAQ, pricing, reviews and data safety. Write it in the developer page's Store tab, or ship it in the package as listing/listing.json with its images in listing/media (SVG is rendered to PNG). media urls are file names there. Texts take one string or one per locale (en, id); an empty translation falls back to English. Paid plugins need a privacy policy and a support email. Every change is reviewed, and Preview public page shows it before approval.

listing/listing.json
{
  "description": {
    "short": { "en": "Ask every new customer one quick question", "id": "Tanya satu pertanyaan ke setiap pelanggan baru" },
    "long": { "en": "Markdown: what it does, who it is for, how it works", "id": "…" }
  },
  "highlights": { "en": ["Asks once, on the first message"], "id": ["Bertanya sekali, di pesan pertama"] },
  "media": {
    "cover": { "url": "cover.svg", "alt": { "en": "Poll: ask every new customer…", "id": "…" } },
    "gallery": [{ "type": "IMAGE", "url": "01.svg", "alt": { "en": "Poll asking a new customer…", "id": "…" } }]
  },
  "faq": [{ "question": { "en": "Can I change the question?", "id": "…" }, "answer": { "en": "Yes…", "id": "…" } }],
  "setup": [{ "en": "Install Poll in your workspace.", "id": "…" }],
  "categories": ["MARKETING", "ANALYTICS"],
  "tags": ["survey", "polling"],
  "languages": ["en", "id"],
  "support": { "email": "[email protected]", "url": "" },
  "links": { "docs": "", "privacy": "https://acme.com/privacy", "terms": "", "website": "https://acme.com", "demo": "" },
  "seo": { "title": { "en": "Poll: WhatsApp survey for new customers", "id": "…" }, "description": { "en": "…", "id": "…" }, "keywords": ["whatsapp survey"] }
}

# bun run build packs listing/ into dist/package.zip; bun run publish submits it for review with the build