Plugin SDK
Build Cola plugins — channels, LLM-callable tools, and cross-session observers.
A Cola plugin is a local Node.js package that extends Cola from outside its codebase. One plugin can play any combination of three roles:
- Channel. Connect an external message platform — team chat, bot accounts, hardware devices, queues — and route messages in and out of Cola.
- Tools. Register LLM-callable tools (Notion search, SQL queries, anything the model should be able to invoke). Tools default to global scope, so a tool-only plugin needs no channel.
- Observer. Subscribe to Cola's session event stream across every session — desktop, CLI, every channel — for analytics, audit mirroring, or cross-channel automation.
Plugins run inside the user's local Cola Server process, so users should install only plugins they trust. For a complete real-world channel project, see the Feishu channel demo.
This page is for plugin developers. If you only want to install or connect an existing plugin, see Channels and Plugins.
Pick a Shape
The three roles compose. Pick the smallest set that fits your goal:
- Channel only. Implement
gateway,outbound, and usuallyauth/config. UsedefineChannel()for the channel-only convenience wrapper, ordefinePlugin({ channel })if you want lifecycle hooks too. - Tools only. Declare top-level
tools[]ondefinePlugin(). No channel required. Tools default toscope: 'global'and become visible to every session in Cola. - Observer only. Implement
start(ctx)and subscribe viactx.runtime.events.on(type, handler, { scope: 'all' })for cross-session activity.
For channel plugins, a minimal implementation usually has four pieces:
gatewaystarts the external listener and callsctx.deliver()for inbound messages.outboundsends Cola's replies back to the platform.configdeclares App IDs, tokens, connection modes, and other settings so Cola can render a setup form.authis optional. It handles QR-code or OAuth login, disconnects, and binding platform sender IDs to the Cola user.
Cola owns the plugin:<pluginId>: scope prefix. Your plugin only chooses the stable sessionId suffix, such as ['dm', userId] for a direct message or ['group', groupId, threadId] for a group thread.
Create a Project
Start with a normal TypeScript package:
my-cola-plugin/
package.json
src/index.ts
tsconfig.jsonInstall the SDK and build tools:
npm install @marswave/cola-plugin-sdk
npm install -D typescript tsupYour package.json needs build scripts, the SDK dependency, and the cola manifest Cola uses to discover the plugin:
{
"name": "cola-plugin-example",
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "tsup src/index.ts --format esm --dts --out-dir dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@marswave/cola-plugin-sdk": "^0.0.2"
},
"devDependencies": {
"tsup": "^8.5.0",
"typescript": "^5.8.0"
},
"cola": {
"plugin": {
"id": "example",
"entry": "./dist/index.js"
},
"channel": {
"label": "Example",
"description": "Example message channel",
"aliases": ["ex"],
"docsPath": "/channels/example"
}
}
}cola.plugin.id must match the id passed to defineChannel(). cola.plugin.entry must point to built JavaScript, not TypeScript source. Set cola.plugin.minColaVersion, for example "0.99.0", only when the plugin uses runtime features that require a specific Cola app release.
Write a Minimal Channel
Create src/index.ts with a channel that can load and self-test:
import { defineChannel } from '@marswave/cola-plugin-sdk'
import type { GatewayContext, OutboundContext } from '@marswave/cola-plugin-sdk'
type GatewayState = {
startedAt?: number
}
export default defineChannel<GatewayState>({
id: 'example',
meta: {
label: 'Example',
description: 'Example message channel',
markdownCapable: true
},
capabilities: {
receive: { text: true },
send: { text: true, markdown: true }
},
gateway: {
async start(ctx: GatewayContext<GatewayState>) {
ctx.state.startedAt = Date.now()
const senderId = 'owner'
// This fixed sender is useful for development self-tests. Real plugins
// usually bind platform users in auth.login() or a pairing command.
await ctx.runtime.identity.bind(senderId)
await ctx.deliver({
sessionId: ['dm', senderId],
sender: { id: senderId, name: 'Owner' },
deliveryContext: { to: senderId },
message: 'Hello from the example plugin'
})
},
async stop(ctx) {
ctx.logger.info('Example channel stopped')
},
getStatus() {
return { connected: true, configured: true }
}
},
outbound: {
async sendText(ctx: OutboundContext) {
ctx.logger.info(`Send reply to ${ctx.deliveryContext.to}: ${ctx.text}`)
}
}
})This example delivers one test message when the plugin starts. For a real platform, replace that test delivery with a WebSocket, webhook, long-poll loop, or queue consumer from the platform SDK.
Deliver Inbound Messages
Every accepted platform message should become one ctx.deliver() call:
await ctx.deliver({
sessionId: ['dm', senderId],
sender: {
id: senderId,
name: senderName
},
deliveryContext: {
to: senderId,
accountId: accountId,
threadId: threadId,
messageId: platformMessageId
},
message: text,
attachments: localFilePaths
})Important fields:
sessionIddecides which Cola conversation receives the message. Keep it stable for the same DM or group thread.sender.idis the platform user ID. Cola uses it to check whether that sender is bound to the local user.deliveryContext.tois passed back tooutbound.sendText(). Store enough routing data to reply on the original platform.attachmentsis optional and can include local file paths for images or files.
By default, messages from an unbound sender.id are ignored. Bind senders after login, pairing, or admin approval with ctx.runtime.identity.bind(senderId).
Support Group Chats
A delivered message with no conversation is treated as a direct (DM) message, which is fully backward compatible. To support group chats, set conversation on the deliver payload and report whether the bot was @mentioned with mentionedBot:
await ctx.deliver({
sessionId: ['group', groupId],
sender: { id: senderId, name: senderName },
// kind is 'direct' | 'group' | 'channel'; id is the group/channel id
// (or the peer id for direct conversations).
conversation: { kind: 'group', id: groupId },
mentionedBot, // detect this from the inbound message — required for group/channel
message: text
})A group or channel message without mentionedBot: true is silently ignored, so the agent only joins a group conversation when it is explicitly addressed. Detecting the mention is the plugin's job, since only the platform SDK knows the bot's own handle and mention syntax.
Authorize Senders and Groups
Access is granted on two axes:
- DMs are authorized per sender, and a plugin can also bind one itself through
runtime.identity.bind(). - Groups are authorized as a whole. Every member of an allowed group can drive Cola, so you do not list each sender.
An unauthorized DM, or a group that @mentions the bot but is not authorized, receives a guidance hint. Override the wording with channel.unauthorizedHint(target), where target is { kind: 'user' | 'group', id }.
Share a Session with the Desktop
By default every conversation runs in its own isolated session. Set sessionBinding: 'shared-primary' on the channel to fold its authorized direct (1:1) conversations into the shared primary session — the same session as the desktop app — so the agent keeps one unified context across the desktop and the channel:
export default defineChannel<GatewayState>({
id: 'example',
meta: { label: 'Example', description: 'Example channel' },
capabilities: { receive: { text: true }, send: { text: true } },
// 'isolated' (default) | 'shared-primary'
sessionBinding: 'shared-primary',
// ...
})Notes:
- Only authorized direct conversations fold in. Authorizing a sender grants it shared, desktop-equivalent access — there is no separate delegation model, so authorize accordingly.
- Group/channel conversations always stay isolated, regardless of
sessionBinding. Always tag group messages withconversation: { kind: 'group', id }so they are never folded into the desktop session. - Replies still go back to the channel; the exchange is additionally mirrored into the desktop UI. Proactive messages for the shared session are delivered to the desktop only.
scope: 'own-channel'tools are not available inside the shared session — they are resolved per plugin scope, and the shared session is the desktop scope. Keep tools your shared-primary channel needs in the agent asscope: 'global'.
Choose Models per Message
Choose a catalog option with runtime.models.list({ query?: string }), then pass its
id as modelId on each deliver() call. The id identifies a catalog
entry, not a provider's raw model name.
const options = await ctx.runtime.models.list({ query: 'Max' })
// The business application owns the selection and passes it on each message.
await ctx.deliver({
sessionId: ['project', 'one', 'chat', 'a'],
sender: { id: 'authorized-user' },
message: 'Continue this conversation',
modelId: options.find((option) => option.selectable)?.id,
})Each option is { id, name, billing: 'plan' | 'credits', selectable, isFree }.
Search matches ids and display names case-insensitively. The list includes public
catalog options; selectable reflects account access when queried.
An explicit modelId applies to that message only. Successive messages in the
same session can use A, B, and C. Omit the field to use Cola's existing model
resolution at execution time; a previous explicit choice does not become a default.
Unknown, hidden, malformed, or unauthorized ids fail through the existing channel
error path instead of falling back. Execution rechecks the catalog and access.
Queued messages retain their ids, and different choices are not collected into one turn. A running reply is unaffected by later deliveries. Existing history, model-switch handling and memory policy are reused. The parameter selects the main model for this message; auxiliary model-family resolution, compaction and background-task inheritance retain their existing behavior.
The plugin owns picker UI, saved selections, and display of the last choice. There
is no session model get/set API or model-selection storage. runtime.llm.* and
builtin /model commands keep their existing behavior. This API does not change
session binding or add a WebSocket RPC.
Send Replies Back
When Cola finishes a response in a channel conversation, it calls outbound.sendText():
outbound: {
async sendText(ctx: OutboundContext) {
const recipient = ctx.deliveryContext.to
await platformClient.sendMessage({
to: recipient,
text: ctx.text
})
}
}If the platform supports images, files, typing indicators, or reactions, implement sendMedia(), sendTyping(), or sendReaction(). Declare only the capabilities you actually implement in capabilities.send.
For media delivery, sendMedia() receives a local filePath, a MIME mediaType, and the same routing context as sendText(). Add mediaCapabilities so Cola can enforce the platform's upload limits before it calls your adapter:
outbound: {
mediaCapabilities: {
supportedKinds: ['image', 'file'],
maxBytesPerFile: 25 * 1024 * 1024,
maxBytesTotal: 50 * 1024 * 1024,
maxCount: 4
},
async sendMedia(ctx) {
await platformClient.uploadFile({
to: ctx.deliveryContext.to,
filePath: ctx.filePath,
mediaType: ctx.mediaType,
caption: ctx.text
})
}
}supportedKinds can contain 'image', 'video', 'audio', and 'file'. Omit it to accept every media kind already enabled in capabilities.send; use the byte and count limits to match the remote platform's attachment contract.
Surface Slash Commands
Cola exposes a subset of its builtin slash commands (such as /new, /help, and /model) inside channel conversations. This needs nothing from your plugin: a leading-slash message is matched against the exposed commands before it reaches the agent, and a bare / message returns the localized command list as text. Unmatched /xxx text flows to the agent unchanged.
If the platform has a native bot command menu (like Telegram's), declare commandMenu so the host can push the command list for you to register:
commandMenu: {
async sync(commands: ChannelCommandMenuEntry[]) {
await platformClient.setMyCommands(
commands.map((cmd) => ({ command: cmd.name, description: cmd.description }))
)
}
}The host calls sync() at gateway start. Each ChannelCommandMenuEntry carries the command name (without the / prefix), a description localized to the user's language setting, and optional args. Platforms without a native menu simply omit the adapter.
Register Custom Tools
A plugin can expose tools the agent can call. Use this for capability-style actions the model should be able to take directly — searching Notion, running a SQL query, looking up a user, posting a reaction. Tools live on the top-level tools[] slot of definePlugin(), regardless of whether the plugin also declares a channel.
import { definePlugin } from '@marswave/cola-plugin-sdk'
export default definePlugin({
id: 'notion',
meta: { label: 'Notion', description: 'Notion integration' },
tools: [
{
name: 'search',
label: 'Search Notion',
description: 'Search the Notion workspace.',
parameters: {
type: 'object',
properties: { q: { type: 'string' } },
required: ['q']
},
async execute(input, ctx) {
const { q } = input as { q: string }
const hits = await searchNotion(q, { signal: ctx.signal })
return { content: [{ type: 'text', text: JSON.stringify(hits) }] }
}
}
]
})Important details:
- Scope. Each tool declares
scope:'global'(default) — visible in every session: desktop, CLI, and every plugin channel. Use this for capability tools.'own-channel'— visible only when a session under this plugin's own channel scope is active. Requires the plugin to also declarechannel. Use this for platform-specific actions tied to the plugin's own channel.
- Naming. Cola namespaces every tool to the LLM as
plugin__<pluginId>__<name>. This avoids collisions with built-in tools likebash,read,edit. Your code keeps using the original name everywhere — the prefix is only what the model sees. - Parameters.
parametersis a JSON Schema object. Typebox schemas are JSON Schema at runtime, soType.Object(...)output works too. - Execution.
execute(input, ctx)receives the parsed input plus aPluginToolContextwithsessionId(undefined for desktop/CLI scopes),scopeKey,toolCallId, anAbortSignal(use it forfetch/ DB calls so the agent's Esc can cancel them), the pluginlogger, and the readonlyconfig. Return{ content, details?, isError? }. Thrown errors are caught and surfaced to the agent asisError: true. - Optional metadata. Set
promptSnippetto add a one-line entry in the system prompt's "Available tools" section, orpromptGuidelinesto append usage guidance bullets when the tool is active. Name the tool explicitly in each guideline — bullets are appended flat with no automatic prefix.
A Tool-Only Plugin Needs No Channel
If your plugin only contributes tools, skip channel, gateway, outbound, and auth. A definePlugin({ id, meta, tools }) is enough; the host registers the tools on load and they become globally available.
Listen to Session Events
The plugin runtime exposes a typed event surface that covers the full agent execution — session lifecycle, compaction, agent loop, turn boundaries, the assistant message stream, the tool boundary, and runtime tool execution. Use it for analytics, mirroring activity into the platform's own audit log, or forwarding interesting events to an external system.
Subscriptions are registered in Plugin.start (or gateway.start for channel plugins). The optional third argument lets you choose between watching only this plugin's own sessions and watching every session in Cola.
import { definePlugin } from '@marswave/cola-plugin-sdk'
export default definePlugin({
id: 'observer',
meta: { label: 'Observer', description: 'Cross-session activity mirror' },
async start({ runtime }) {
// Default scope: 'self' — only sessions under plugin:observer:*
runtime.events.on('turn:end', (e) => {
runtime.logger.info(`turn ${e.turnIndex} done on ${e.origin.kind}`)
})
// Cross-session firehose: every session in Cola, including desktop & CLI
runtime.events.on(
'tool:call',
(e) => {
runtime.logger.info(`[${e.origin.kind}] call ${e.toolName} #${e.toolCallId}`)
},
{ scope: 'all' }
)
}
})Ground rules:
- Read-only. Listener return values are ignored. Use a custom tool with explicit confirmation logic if you need to gate or modify behavior.
- Timeouts. Each handler runs under a 2-second timeout so a slow plugin cannot block the agent. Keep listener work fast or fire-and-forget.
- Scope. Pass
{ scope: 'self' }(default) to receive only this plugin's sessions, or{ scope: 'all' }to receive every session — desktop, CLI, and other plugins. Every payload carriesorigin: { kind: 'plugin' | 'desktop' | 'cli' | 'other', ... }so cross-scope handlers can tell where the event came from. - Tool naming.
toolNameis always the original, unprefixed name. Built-in Cola tools come through asbash,read, etc.; your own tools come through aslookup_user, notplugin__example__lookup_user. - No raw LLM payload. User prompts, the system prompt, and the full assistant
messagesarray are deliberately stripped before fan-out. Onlyassistant-role messages and text deltas reachmessage:*.
For the full event catalogue, payload shapes, and the explicit list of fields that are not forwarded, see Plugin Session Events.
Expose a Settings Form
config.schema.fields is rendered in Cola's channel settings UI. Supported field types are text, password, number, boolean, and select:
config: {
schema: {
fields: [
{
key: 'appId',
path: ['accounts', 'default', 'appId'],
label: 'App ID',
type: 'text',
required: true,
placeholder: 'cli_xxx'
},
{
key: 'appSecret',
path: ['accounts', 'default', 'appSecret'],
label: 'App Secret',
type: 'password',
required: true,
secret: true
},
{
key: 'connectionMode',
path: ['accounts', 'default', 'connectionMode'],
label: 'Connection',
type: 'select',
defaultValue: 'websocket',
options: [
{ label: 'WebSocket', value: 'websocket' },
{ label: 'Webhook', value: 'webhook' }
]
}
]
}
}Adapters read the current settings from ctx.config. After settings change, Cola can restart the gateway when requested, and plugins can also call ctx.runtime.reloadGateway?.() when they need to restart their receive loop.
Write Config Back at Runtime
ctx.runtime.config.patch(partial) shallow-merges partial into this plugin's own persisted config. Only the keys in partial change; everything else is preserved. It hot-updates the running config with no restart, and a plugin can only ever write its own config — the host binds the plugin id internally. This is the way to persist credentials a plugin obtains at runtime, for example after a scan-to-register pairing flow:
const { token, accountId } = await completePairing()
await ctx.runtime.config.patch({ token, accountId })The reserved key pluginDir is ignored if present in partial.
Handle Login and Binding
If the platform needs user authorization, implement auth.login():
auth: {
async login(ctx) {
ctx.onStatus?.('waiting', 'Waiting for platform authorization')
const result = await runYourLoginFlow({
onQrCode: (imageSrc, rawContent) => ctx.onQrCode?.(imageSrc, rawContent)
})
await ctx.runtime.identity.bind(result.senderId)
ctx.onStatus?.('connected', 'Connected')
await ctx.runtime.reloadGateway?.()
},
async disconnect(ctx) {
const senderIds = await loadBoundSenderIdsSomewhere()
for (const senderId of senderIds) {
await ctx.runtime.identity.unbind(senderId)
}
await ctx.runtime.reloadGateway?.()
}
}ctx.onQrCode() displays a QR code in Cola settings. ctx.onStatus() updates the login state. After binding, messages from that sender can enter Cola.
Install and Test Locally
Build the plugin first:
npm run buildFor normal use, open Cola settings, go to the channel panel, click "Install Local Plugin", and pick the plugin directory. Cola reads package.json, installs the plugin under ~/.cola/plugins/<id>/, and refreshes the running plugin list.
Suggested test order:
npm run typechecknpm run build- Install the local plugin
- Open Cola settings and confirm the plugin appears in the channel list
- Configure and connect the plugin
- Send one message from the external platform and confirm Cola replies through the same platform
See a Complete Demo
The Feishu plugin shows the shape of a real channel: settings schema, multi-account config, WebSocket/webhook connection, message monitors, outbound sending, reactions, commands, and platform SDK wrappers.
Read the code: marswaveai/cola-plugins/plugins/feishu.
Compatibility
@marswave/cola-plugin-sdk follows semver for the package that plugin authors install. Runtime compatibility is gated by cola.plugin.minColaVersion: set it only when the plugin needs features from a specific Cola app release. A newer plugin package version or SDK dependency version does not force a Cola update by itself.
Plugin localization
Plugin i18n is available in SDK 0.0.5. Register locale files in package.json:
{
"cola": {
"plugin": { "id": "example", "entry": "./dist/index.js" },
"channel": {
"label": "Example",
"description": "Example messaging channel",
"i18n": {
"en": "./locales/en.json",
"zh-CN": "./locales/zh-CN.json"
}
}
}
}Each file is a flat JSON object mapping message keys to strings. The reserved
label and description keys supply the channel name and introduction, including
its store card before installation. Other keys belong to the plugin. Files must
be JSON files inside the package, referenced by relative paths without ...
{
"label": "示例",
"description": "通过示例渠道与 Cola 对话",
"config.token": "机器人令牌",
"auth.timeout": "登录在 {{seconds}} 秒后超时,请重试。"
}Keep the existing string meta.label and meta.description as defaults. Use
pluginMessage(key, fallback, params?) for configuration field labels,
descriptions, placeholders and option labels; gateway status messages; auth
status messages; command descriptions, argument descriptions and replies; and
unauthorizedHint. Plain strings continue to work. Keep IDs, command names,
configuration keys and option values stable.
import { pluginMessage, PluginLocalizedError } from '@marswave/cola-plugin-sdk'
const field = {
key: 'botToken',
type: 'password' as const,
label: pluginMessage('config.token', 'Bot token')
}
throw new PluginLocalizedError(
pluginMessage('auth.timeout', 'Login timed out after {{seconds}} seconds. Please retry.', {
seconds: 30
}),
{ cause: originalError }
)Messages serialize as { key, fallback, params? }. Parameters accept strings,
numbers, booleans and nested messages. Use joinPluginText(parts, separator?)
to compose dynamic command replies without translating them early. Plugin keys
are isolated; they cannot overwrite Cola or another plugin's translations.
The desktop resolves text in its current UI language and updates visible text
when the language changes. The server translates command replies and authorization
hints at delivery using Cola's language setting. For a plugin that sends text
directly through its platform client, use await ctx.runtime.i18n!.text(message)
at the send site. A host supporting i18n provides this optional runtime capability.
Each field falls back from the exact UI locale to en, then to its original
fallback. Empty translations count as missing. Simplified and traditional Chinese
do not fall back to one another. Partial catalogs are supported. Cola currently
has en, es, ja, ko, zh-CN and zh-TW UI languages. Locale keys are
case-insensitive; use canonical tags in package metadata.
The UI displays a localized error summary and expandable original details.
ChannelStatusResult.details can carry raw status diagnostics separately from message.
Unexpected errors get a generic localized summary; logs keep original errors.
Malformed or missing locale files produce runtime diagnostics and use fallback
text rather than preventing the plugin from loading. Publish validation rejects
missing files, invalid JSON, non-string values, escaping paths and mismatched
{{parameter}} names across translations. A catalog is limited to 1 MiB.
resolvePluginText, validatePluginCatalog and validatePluginTranslations are
pure helpers. Node tooling can import loadPluginTranslations from
@marswave/cola-plugin-sdk/i18n-files; pass { strict: true } for publish
validation, or { onWarning } to retain valid locales on runtime failures.
The bundled-plugin check and official plugin publication scripts also compare
source pluginMessage calls with every catalog. For messages with a nonempty key,
keep the key and fallback as string literals; put dynamic values in params.
The check supports SDK import aliases and namespace imports without executing
plugin code. Missing or empty translations remain valid fallbacks.
Publish SDK 0.0.5 before releasing plugins that depend on it, and set
cola.plugin.minColaVersion to the first released Cola version supporting i18n.
In cola-plugins, pnpm build:registry validates all declared catalogs and embeds
only label/description translations in the store index. Release packaging
copies every registered locale file; the installed host reads the full catalogs.