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 usually auth / config. Use defineChannel() for the channel-only convenience wrapper, or definePlugin({ channel }) if you want lifecycle hooks too.
  • Tools only. Declare top-level tools[] on definePlugin(). No channel required. Tools default to scope: 'global' and become visible to every session in Cola.
  • Observer only. Implement start(ctx) and subscribe via ctx.runtime.events.on(type, handler, { scope: 'all' }) for cross-session activity.

For channel plugins, a minimal implementation usually has four pieces:

  1. gateway starts the external listener and calls ctx.deliver() for inbound messages.
  2. outbound sends Cola's replies back to the platform.
  3. config declares App IDs, tokens, connection modes, and other settings so Cola can render a setup form.
  4. auth is 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.json

Install the SDK and build tools:

npm install @marswave/cola-plugin-sdk
npm install -D typescript tsup

Your 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:

  • sessionId decides which Cola conversation receives the message. Keep it stable for the same DM or group thread.
  • sender.id is the platform user ID. Cola uses it to check whether that sender is bound to the local user.
  • deliveryContext.to is passed back to outbound.sendText(). Store enough routing data to reply on the original platform.
  • attachments is 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: cola channel allow <pluginId> <senderId>.
  • Groups are authorized as a whole: cola channel allow-group <pluginId> <groupId>. 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 that names the command to run. Override the wording with channel.unauthorizedHint(target), where target is { kind: 'user' | 'group', id }. Manage authorization with cola channel allow, revoke, allow-group, revoke-group, and allowlist.

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.

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 declare channel. 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 like bash, read, edit. Your code keeps using the original name everywhere — the prefix is only what the model sees.
  • Parameters. parameters is a JSON Schema object. Typebox schemas are JSON Schema at runtime, so Type.Object(...) output works too.
  • Execution. execute(input, ctx) receives the parsed input plus a PluginToolContext with sessionId (undefined for desktop/CLI scopes), scopeKey, toolCallId, an AbortSignal (use it for fetch / DB calls so the agent's Esc can cancel them), the plugin logger, and the readonly config. Return { content, details?, isError? }. Thrown errors are caught and surfaced to the agent as isError: true.
  • Optional metadata. Set promptSnippet to add a one-line entry in the system prompt's "Available tools" section, or promptGuidelines to 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 carries origin: { kind: 'plugin' | 'desktop' | 'cli' | 'other', ... } so cross-scope handlers can tell where the event came from.
  • Tool naming. toolName is always the original, unprefixed name. Built-in Cola tools come through as bash, read, etc.; your own tools come through as lookup_user, not plugin__example__lookup_user.
  • No raw LLM payload. User prompts, the system prompt, and the full assistant messages array are deliberately stripped before fan-out. Only assistant-role messages and text deltas reach message:*.

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 build

For 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.

When developing Cola itself, use the CLI and server from the same checkout:

pnpm cli:dev plugin install /path/to/plugin
pnpm cli:dev channel login example
pnpm cli:dev channel status

Suggested test order:

  1. npm run typecheck
  2. npm run build
  3. Install the local plugin
  4. Open Cola settings and confirm the plugin appears in the channel list
  5. Configure and connect the plugin
  6. 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.

Troubleshooting

  • Plugin does not appear: check that package.json has cola.plugin.id and cola.plugin.entry, and that entry points to built JavaScript.
  • Inbound messages do not trigger Cola: check that sender.id has been bound with runtime.identity.bind().
  • Cola receives messages but cannot reply: check that deliveryContext.to contains enough routing data for outbound.sendText().
  • Settings fields do not appear: check that config.schema.fields is an array and each field has key, label, and type.
  • Code changes do not apply: rebuild and reinstall the plugin, or refresh the running Cola Server.

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.

On this page