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.
| Part | What it does |
|---|---|
| actions | The 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. |
| inbound | Read every inbound message; claim the conversation, reply, keep state, then release it. |
| on | One event bus: Beamvia's events, schedules (schedules) and other plugins' events; runs in the background. |
| routes | Public pages (as blocks) and webhooks with the raw body, under /public/<workspace>/<plugin>/… |
| collections | Storage with indexes, unique keys, text search and encrypted fields; atomic claim and counters. Fields in contact are deleted with the contact. |
| billing | Items the plugin sells on top (credits, packs); prices, and the one-time workspace license, are set in the store. |
| ai | Instructions and live per-conversation context added to the agent when its tools are on. |
| ui | Navigation (own sidebar group, Plugins group or settings tab), pages and panels. |
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 SVGServer 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
| field | What it does |
|---|---|
| claim | Take the conversation; nothing else answers until you release it. |
| release | Give the conversation back to the channel's default handler. |
| state | Saved on the conversation and passed to your next onMessage while you own it. |
| replies | Messages sent in order (text, media, location, contact, PLUGIN cards). |
| stop | End the pipeline for this message without claiming (no auto reply, no AI). |
Events
| event | What it does |
|---|---|
| plugin.installed | Installed (or installed again within the retention). |
| plugin.upgraded | The workspace moved to another build ({ from, to, isRollback }). |
| plugin.billing.updated | An item was bought. |
| inbox.message.created | A message in any conversation (never your own). |
| inbox.conversation.updated | Status, handler or tags changed. |
| contact.updated | A contact changed. |
| contact.merged | Two contacts became one: move what you keep to primaryId. |
| contact.deleted | Remove 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.
| ctx | What it does |
|---|---|
| ctx.config | Settings values (SECRET fields decrypted, server only). |
| ctx.storage | insert, insertMany, find (sort, skip, limit, search), findOne, count, group, update, claim, upsert, remove, next (counters). |
| ctx.inbox | Send messages and cards, write private notes, add tags, change status, open a conversation with a contact. |
| ctx.contacts | Read, update, resolve by phone or email, add identities and memories. |
| ctx.template | Render a template with the conversation's variables and your own data. |
| ctx.plugins | Call another installed plugin's action. |
| ctx.events | Emit "<pluginKey>:<name>" to listening plugins and webhooks. |
| ctx.billing | Item balances; consume purchased items. |
| ctx.notification | Bell notifications for members with a permission. |
| ctx.http | HTTPS to the hosts in manifest.network (10 s, 2 MB, no private addresses). |
| ctx.files | Store 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.email | A plain email from the platform address. |
| ctx.realtime | Live updates to your open pages (useRealtime). |
| ctx.ui | Badge counts on navigation items. |
| ctx.url | Absolute links to your public routes and dashboard pages. |
| ctx.ai | The workspace's AI agents and which of your tools each has on. |
| ctx.logger | Server logs that also appear in the install's activity. |
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/client | What it does |
|---|---|
| useQuery | Read through an action, cached per input. |
| useAction | Run an action once per click; refreshes your queries, toasts failures. |
| usePlugin | The plugin, the workspace and can(permission). |
| useRealtime | Listen to what ctx.realtime.publish sends. |
| useBilling | Item balances and checkout. |
| useNavigate | Go to another page of your plugin. |
| useCommandCenter | Inside a Command Center window or sidebar: open another of the plugin's windows, or a chat, as a Command Center window |
| toast | Success and error toasts. |
Slots
| slot | props | What it does |
|---|---|---|
| conversation.panel | conversationId, contactId | Below the conversation details in the inbox. |
| contact.detail | contactId | On the contact page, next to memory and identities. |
| inbox.composer.actions | conversationId, contactId, insertText, send | Buttons next to the composer; insertText adds to the draft, send posts a message (e.g. a card) as the member. |
| inbox.message.actions | conversationId, contactId, message | Items in a message's menu (e.g. attach as payment proof). |
| inbox.message.render | conversationId, kind, data, direction | Draws your PLUGIN messages in the inbox; channels get the fallback. |
| inbox.conversation.banner | conversationId, contactId | A strip under the chat header, next to the declared banner |
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 |
|---|---|
| metrics | Numbers 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 |
| commands | Menu items that run a plugin action on rows, selections, the chat header, the contact page, Command Center tabs and ⌘K |
| banner | A strip under the chat header, with an optional command button |
| hover | A section (blocks) in the Command Center contact hover card |
| commandCenter.sources | A list of the plugin's own records, read straight from one of its collections |
| commandCenter.views | Saved filters on a source, shown as tabs; records dragged between them change their writable fields |
| commandCenter.templates | Starting arrangements of the plugin's views |
| commandCenter.windows | Windows the client draws (definePluginClient commandCenter.windows), opened by sources, commands or useCommandCenter() |
| commandCenter.status | Status bar counts, updated with ctx.ui.status(key, count) |
| Rule | What it does |
|---|---|
| format | Metric 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 |
| target | Command 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 } |
| isolation | A plugin only ever sees the workspace it is installed in; its windows and sidebar run in an iframe of that workspace |
| index | Every 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 |
| permission | Each metric, command, source, window and status item may set a permission (default plugin.use); members without it never receive it |
// ** 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
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.
# 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 → publishesStore 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.
{
"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