Skip to content

Plugin Development

RivetOS supports five plugin types: Providers (talk to LLMs), Channels (send/receive messages), Tools (agent capabilities), Memory (persistent storage), and Transports (expose RivetOS to external clients). Every plugin uses the same self-registration contract.


Terminal window
npx rivetos plugin init
# or directly:
npx nx g @rivetos/nx:plugin
# ? What type of plugin? › channel
# ? What is the plugin name? › slack

This creates plugins/{category}/{name}/ with package.json, tsconfig.json, src/index.ts, and a test file.


  1. Depend on @rivetos/types only. Plugins do not import from @rivetos/core or @rivetos/boot. (The memory plugin’s workers and the MCP server transport are exceptions because they ship binaries that run outside the runtime.)
  2. Export a manifest: PluginManifest const. This is the entry point boot uses.
  3. Declare package.json#rivetos. This is what discovery reads — without importing the package.
  4. Handle platform concerns internally. Message splitting, rate limits, API quirks — all inside the plugin.
{
"name": "@rivetos/channel-slack",
"rivetos": {
"type": "channel",
"name": "slack"
}
}

Boot scans every plugins/*/*/package.json (and any plugin_dirs from config). The descriptor’s type and name must match the exported manifest.


import type { PluginManifest, RegistrationContext } from '@rivetos/types'
export const manifest: PluginManifest = {
type: 'provider', // 'provider' | 'channel' | 'tool' | 'memory' | 'transport'
name: 'mistral',
async register(ctx: RegistrationContext) {
// 1. Read your config slice
const cfg = ctx.pluginConfig as { model?: string; api_key?: string } | undefined
const apiKey = cfg?.api_key ?? ctx.env.MISTRAL_API_KEY ?? ''
// 2. Construct your plugin
const provider = new MistralProvider({ model: cfg?.model ?? 'mistral-large-latest', apiKey })
// 3. Register with the runtime
ctx.registerProvider(provider)
// 4. (Optional) Register cleanup
ctx.registerShutdown(() => provider.close())
},
}

RegistrationContext exposes:

MemberPurpose
configFull validated RivetConfig (cast in your plugin if you need it)
pluginConfigThe slice for this plugin — config.providers[name], config.channels[name], config.memory[name], config.transports[name]. undefined for tool plugins (tools read from config directly when needed).
envprocess.env snapshot
workspaceDirResolved workspace path
loggerScoped logger (debug / info / warn / error)
registerProvider / registerChannel / registerTool / registerMemoryHand instances to the runtime
registerHook(hook)Subscribe to lifecycle events
registerShutdown(fn)Called during graceful shutdown
lateBindTool(name)Returns a closure that resolves a tool at execution time — used by composite tools when registration order isn’t guaranteed
onRegistrationComplete(fn)Fires once after every plugin has registered. Receives { tools }. Used by transports to enumerate the finalized tool set before opening their listening socket.

Boot has no per-plugin knowledge. Every kind of plugin goes through the same loader (packages/boot/src/registrars/plugins.ts).

A plugin is discovered by package.json#rivetos, but only activated when:

  • Provider / channel / memory / transport — its name appears in the matching config section (config.providers[name], config.channels[name], config.memory[name], config.transports[name]). Channels also accept a legacy alias: voice-discord matches config.channels.voice.
  • Tool — always activated (tools decide internally whether their config is sufficient — e.g. mcp-client skips itself when no servers are configured).

interface Provider {
id: string
name: string
chatStream(messages: Message[], options?: ChatOptions): AsyncIterable<LLMChunk>
chat?(messages: Message[], options?: ChatOptions): Promise<LLMResponse>
isAvailable(): Promise<boolean>
getModel(): string
setModel(model: string): void
}

The AgentLoop always calls chatStream(). chat() is optional. getModel() / setModel() enable runtime model switching via /model.

interface LLMChunk {
type: 'text' | 'tool_call' | 'thinking' | 'done' | 'error'
text?: string
toolCall?: ToolCall
usage?: TokenUsage
error?: string
}
ProviderPathNotable
Anthropicplugins/providers/anthropic/Adaptive thinking, prompt caching
xAIplugins/providers/xai/Live search, conversation caching
Googleplugins/providers/google/Thought signatures for function calling
Ollamaplugins/providers/ollama/Native API
vllmplugins/providers/vllm/vLLM server — full vLLM surface (sampling extensions, mm/chat_template kwargs, video, reasoning_content); folds mid-conversation system messages
llama-serverplugins/providers/llama-server/llama.cpp llama-server — lean (top_k/min_p + extra_body); folds mid-conversation system messages
claude-cliplugins/providers/claude-cli/Drives the claude binary via stream-json; embedded MCP bridge for hybrid tools

interface Channel {
id: string
platform: string
start(): Promise<void>
stop(): Promise<void>
send(message: OutboundMessage): Promise<string | null>
edit?(channelId, messageId, text, overflowIds?): Promise<EditResult | null>
react?(messageId, emoji, channelId): Promise<void>
startTyping?(channelId): void
stopTyping?(channelId): void
resolveAttachment?(attachment): Promise<ResolvedAttachment | null>
onMessage(handler: MessageHandler): void
onCommand(handler: CommandHandler): void
}

The runtime calls edit() repeatedly while streaming. Channels handle:

  • Throttling — don’t hit the platform on every token
  • Splitting — when text exceeds the platform limit, split into overflow messages and report the IDs back via EditResult
  • Typing — show while the agent is working
ChannelPathNotable
Discordplugins/channels/discord/Streaming edits, overflow, reactions, embeds
Telegramplugins/channels/telegram/Owner gate, inline keyboards, 4096-char splitting
Agentplugins/channels/agent/HTTPS/mTLS inter-agent + mesh endpoints
Voice (Discord)plugins/channels/voice-discord/xAI Realtime API + Opus codec

interface Tool extends ToolDefinition {
execute(args, signal?, context?): Promise<ToolResult>
}
interface ToolDefinition {
name: string
description: string
parameters: object // JSON Schema
}
  • signalAbortSignal from the turn. Honor it.
  • context — workspace path, agent name, config, etc.
  • ToolResultstring for text, ContentPart[] for multimodal (text + images).
Tool pluginTools registeredNotable
tool-shellshellSafety categorization, cwd tracking, timeout
tool-filefile_read, file_write, file_editSurgical edits, line numbers, optional backups
tool-searchsearch_glob, search_grepGlob and grep with file pattern filtering
tool-web-searchinternet_search, web_fetchGoogle CSE + DuckDuckGo fallback, HTML → markdown
tool-interactionask_user, todoStructured questions, session-scoped task list
tool-mcp-clientdynamicConnects to MCP servers (stdio + HTTP), exposes their tools

The memory plugin (@rivetos/memory-postgres) additionally registers memory_search, memory_browse, memory_stats. Delegation, sub-agents, and skill management add delegate_task, subagent_*, and skill_* tools at runtime.


interface Memory {
append(entry: MemoryEntry): Promise<string>
search(query, options?): Promise<MemorySearchResult[]>
getContextForTurn(query, agent, options?): Promise<string>
getSessionHistory(sessionId, options?): Promise<Message[]>
saveSessionSettings?(sessionId, settings): Promise<void>
loadSessionSettings?(sessionId): Promise<Record<string, unknown> | null>
}

The PostgreSQL memory plugin (plugins/memory/postgres/) is the reference. It implements full transcript storage, hybrid FTS + vector search, summary DAG (hierarchical compaction), event-driven embedding/compaction workers (running as Datahub services on Postgres LISTEN/NOTIFY), temporal decay scoring, and a review loop for pattern extraction. SQL DDL lives co-located in plugins/memory/postgres/schema/.

See MEMORY-DESIGN.md for the full design.


Transports expose RivetOS to external clients. They have no core interface — the plugin opens its own listening surface (HTTP, stdio, gRPC, …) inside manifest.register().

export const manifest: PluginManifest = {
type: 'transport',
name: 'mcp',
async register(ctx) {
const cfg = ctx.pluginConfig ?? {}
const server = createMcpServer(cfg)
// Defer binding until every other plugin has registered, so the
// transport sees the full tool set.
ctx.onRegistrationComplete(async ({ tools }) => {
server.registerTools(tools)
await server.listen()
})
ctx.registerShutdown(() => server.close())
},
}

Activate via config.transports.<name>:

transports:
mcp:
port: 4321
tls: true

Reference: plugins/transports/mcp-server/ — a StreamableHTTP MCP server exposing memory_*, web_*, skill_*, and runtime tools to external MCP clients.


Terminal window
npx nx run provider-mistral:test # one plugin
npx nx affected -t test # only what changed

Co-locate tests next to source: src/index.tssrc/index.test.ts. The framework is Vitest.


plugins/{category}/{name}/
├── package.json # @rivetos/{category}-{name} (or @rivetos/{name} for transports)
├── tsconfig.json # extends ../../../tsconfig.base.json
├── eslint.config.mjs # inherits shared config
└── src/
├── index.ts # exports `manifest` + your impl
├── index.test.ts
└── ...

package.json must include the rivetos descriptor:

{
"name": "@rivetos/provider-mistral",
"version": "0.4.0-beta.x",
"private": true,
"rivetos": { "type": "provider", "name": "mistral" },
"dependencies": { "@rivetos/types": "workspace:*" }
}