Plugin Internals
This is the deep architecture reference. For practical guides, see:
- Install and use plugins — user guide
- Getting Started — first plugin tutorial
- Channel Plugins — build a messaging channel
- Provider Plugins — build a model provider
- SDK Overview — import map and registration API
Public capability model
Capabilities are the public native plugin model inside Velaclaw. Every native Velaclaw plugin registers against one or more capability types:
A plugin that registers zero capabilities but provides hooks, tools, or
services is a legacy hook-only plugin. That pattern is still fully supported.
External compatibility stance
The capability model is landed in core and used by bundled/native plugins today, but external plugin compatibility still needs a tighter bar than “it is exported, therefore it is frozen.” Current guidance:- existing external plugins: keep hook-based integrations working; treat this as the compatibility baseline
- new bundled/native plugins: prefer explicit capability registration over vendor-specific reach-ins or new hook-only designs
- external plugins adopting capability registration: allowed, but treat the capability-specific helper surfaces as evolving unless docs explicitly mark a contract as stable
- capability registration APIs are the intended direction
- legacy hooks remain the safest no-breakage path for external plugins during the transition
- exported helper subpaths are not all equal; prefer the narrow documented contract, not incidental helper exports
Plugin shapes
Velaclaw classifies every loaded plugin into a shape based on its actual registration behavior (not just static metadata):- plain-capability — registers exactly one capability type (for example a
provider-only plugin like
mistral) - hybrid-capability — registers multiple capability types (for example
openaiowns text inference, speech, media understanding, and image generation) - hook-only — registers only hooks (typed or custom), no capabilities, tools, commands, or services
- non-capability — registers tools, commands, services, or routes but no capabilities
velaclaw plugins inspect <id> to see a plugin’s shape and capability
breakdown. See CLI reference for details.
Legacy hooks
Thebefore_agent_start hook remains supported as a compatibility path for
hook-only plugins. Legacy real-world plugins still depend on it.
Direction:
- keep it working
- document it as legacy
- prefer
before_model_resolvefor model/provider override work - prefer
before_prompt_buildfor prompt mutation work - remove only after real usage drops and fixture coverage proves migration safety
Compatibility signals
When you runvelaclaw doctor or velaclaw plugins inspect <id>, you may see
one of these labels:
Neither
hook-only nor before_agent_start will break your plugin today —
hook-only is advisory, and before_agent_start only triggers a warning. These
signals also appear in velaclaw status --all and velaclaw plugins doctor.
Architecture overview
Velaclaw’s plugin system has four layers:- Manifest + discovery
Velaclaw finds candidate plugins from configured paths, workspace roots,
global extension roots, and bundled extensions. Discovery reads native
velaclaw.plugin.jsonmanifests plus supported bundle manifests first. - Enablement + validation Core decides whether a discovered plugin is enabled, disabled, blocked, or selected for an exclusive slot such as memory.
- Runtime loading Native Velaclaw plugins are loaded in-process via jiti and register capabilities into a central registry. Compatible bundles are normalized into registry records without importing runtime code.
- Surface consumption The rest of Velaclaw reads the registry to expose tools, channels, provider setup, hooks, HTTP routes, CLI commands, and services.
- parse-time metadata comes from
registerCli(..., { descriptors: [...] }) - the real plugin CLI module can stay lazy and register on first invocation
- discovery + config validation should work from manifest/schema metadata without executing plugin code
- native runtime behavior comes from the plugin module’s
register(api)path
Channel plugins and the shared message tool
Channel plugins do not need to register a separate send/edit/react tool for normal chat actions. Velaclaw keeps one sharedmessage tool in core, and
channel plugins own the channel-specific discovery and execution behind it.
The current boundary is:
- core owns the shared
messagetool host, prompt wiring, session/thread bookkeeping, and execution dispatch - channel plugins own scoped action discovery, capability discovery, and any channel-specific schema fragments
- channel plugins own provider-specific session conversation grammar, such as how conversation ids encode thread ids or inherit from parent conversations
- channel plugins execute the final action through their action adapter
ChannelMessageActionAdapter.describeMessageTool(...). That unified discovery
call lets a plugin return its visible actions, capabilities, and schema
contributions together so those pieces do not drift apart.
When a channel-specific message-tool param carries a media source such as a
local path or remote media URL, the plugin should also return
mediaSourceParams from describeMessageTool(...). Core uses that explicit
list to apply sandbox path normalization and outbound media-access hints
without hardcoding plugin-owned param names.
Prefer action-scoped maps there, not one channel-wide flat list, so a
profile-only media param does not get normalized on unrelated actions like
send.
Core passes runtime scope into that discovery step. Important fields include:
accountIdcurrentChannelIdcurrentThreadTscurrentMessageIdsessionKeysessionIdagentId- trusted inbound
requesterSenderId
message tool.
This is why embedded-runner routing changes are still plugin work: the runner is
responsible for forwarding the current chat/session identity into the plugin
discovery boundary so the shared message tool exposes the right channel-owned
surface for the current turn.
For channel-owned execution helpers, bundled plugins should keep the execution
runtime inside their own extension modules. Core no longer owns the Discord,
Slack, Telegram, or WhatsApp message-action runtimes under src/agents/tools.
We do not publish separate plugin-sdk/*-action-runtime subpaths, and bundled
plugins should import their own local runtime code directly from their
extension-owned modules.
The same boundary applies to provider-named SDK seams in general: core should
not import channel-specific convenience barrels for Slack, Discord, Signal,
WhatsApp, or similar extensions. If core needs a behavior, either consume the
bundled plugin’s own api.ts / runtime-api.ts barrel or promote the need
into a narrow generic capability in the shared SDK.
For polls specifically, there are two execution paths:
outbound.sendPollis the shared baseline for channels that fit the common poll modelactions.handleAction("poll")is the preferred path for channel-specific poll semantics or extra poll parameters
Capability ownership model
Velaclaw treats a native plugin as the ownership boundary for a company or a feature, not as a grab bag of unrelated integrations. That means:- a company plugin should usually own all of that company’s Velaclaw-facing surfaces
- a feature plugin should usually own the full feature surface it introduces
- channels should consume shared core capabilities instead of re-implementing provider behavior ad hoc
- the bundled
openaiplugin owns OpenAI model-provider behavior and OpenAI speech + realtime-voice + media-understanding + image-generation behavior - the bundled
elevenlabsplugin owns ElevenLabs speech behavior - the bundled
microsoftplugin owns Microsoft speech behavior - the bundled
googleplugin owns Google model-provider behavior plus Google media-understanding + image-generation + web-search behavior - the bundled
firecrawlplugin owns Firecrawl web-fetch behavior - the bundled
minimax,mistral,moonshot, andzaiplugins own their media-understanding backends - the bundled
qwenplugin owns Qwen text-provider behavior plus media-understanding and video-generation behavior - the
voice-callplugin is a feature plugin: it owns call transport, tools, CLI, routes, and Twilio media-stream bridging, but it consumes shared speech plus realtime-transcription and realtime-voice capabilities instead of importing vendor plugins directly
- OpenAI lives in one plugin even if it spans text models, speech, images, and future video
- another vendor can do the same for its own surface area
- channels do not care which vendor plugin owns the provider; they consume the shared capability contract exposed by core
- plugin = ownership boundary
- capability = core contract that multiple plugins can implement or consume
- define the missing capability in core
- expose it through the plugin API/runtime in a typed way
- wire channels/features against that capability
- let vendor plugins register implementations
Capability layering
Use this mental model when deciding where code belongs:- core capability layer: shared orchestration, policy, fallback, config merge rules, delivery semantics, and typed contracts
- vendor plugin layer: vendor-specific APIs, auth, model catalogs, speech synthesis, image generation, future video backends, usage endpoints
- channel/feature plugin layer: Slack/Discord/voice-call/etc. integration that consumes core capabilities and presents them on a surface
- core owns reply-time TTS policy, fallback order, prefs, and channel delivery
openai,elevenlabs, andmicrosoftown synthesis implementationsvoice-callconsumes the telephony TTS runtime helper
Multi-capability company plugin example
A company plugin should feel cohesive from the outside. If Velaclaw has shared contracts for models, speech, realtime transcription, realtime voice, media understanding, image generation, video generation, web fetch, and web search, a vendor can own all of its surfaces in one place:- one plugin owns the vendor surface
- core still owns the capability contracts
- channels and feature plugins consume
api.runtime.*helpers, not vendor code - contract tests can assert that the plugin registered the capabilities it claims to own
Capability example: video understanding
Velaclaw already treats image/audio/video understanding as one shared capability. The same ownership model applies there:- core defines the media-understanding contract
- vendor plugins register
describeImage,transcribeAudio, anddescribeVideoas applicable - channels and feature plugins consume the shared core behavior instead of wiring directly to vendor code
api.registerVideoGenerationProvider(...) implementations against it.
Need a concrete rollout checklist? See
Capability Cookbook.
Contracts and enforcement
The plugin API surface is intentionally typed and centralized inVelaclawPluginApi. That contract defines the supported registration points and
the runtime helpers a plugin may rely on.
Why this matters:
- plugin authors get one stable internal standard
- core can reject duplicate ownership such as two plugins registering the same provider id
- startup can surface actionable diagnostics for malformed registration
- contract tests can enforce bundled-plugin ownership and prevent silent drift
- runtime registration enforcement The plugin registry validates registrations as plugins load. Examples: duplicate provider ids, duplicate speech provider ids, and malformed registrations produce plugin diagnostics instead of undefined behavior.
- contract tests Bundled plugins are captured in contract registries during test runs so Velaclaw can assert ownership explicitly. Today this is used for model providers, speech providers, web search providers, and bundled registration ownership.
What belongs in a contract
Good plugin contracts are:- typed
- small
- capability-specific
- owned by core
- reusable by multiple plugins
- consumable by channels/features without vendor knowledge
- vendor-specific policy hidden in core
- one-off plugin escape hatches that bypass the registry
- channel code reaching straight into a vendor implementation
- ad hoc runtime objects that are not part of
VelaclawPluginApiorapi.runtime
Execution model
Native Velaclaw plugins run in-process with the Gateway. They are not sandboxed. A loaded native plugin has the same process-level trust boundary as core code. Implications:- a native plugin can register tools, network handlers, hooks, and services
- a native plugin bug can crash or destabilize the gateway
- a malicious native plugin is equivalent to arbitrary code execution inside the Velaclaw process
@velaclaw/<id> by default, or an approved typed suffix such as
-provider, -plugin, -speech, -sandbox, or -media-understanding when
the package intentionally exposes a narrower plugin role.
Important trust note:
plugins.allowtrusts plugin ids, not source provenance.- A workspace plugin with the same id as a bundled plugin intentionally shadows the bundled copy when that workspace plugin is enabled/allowlisted.
- This is normal and useful for local development, patch testing, and hotfixes.
Export boundary
Velaclaw exports capabilities, not implementation convenience. Keep capability registration public. Trim non-contract helper exports:- bundled-plugin-specific helper subpaths
- runtime plumbing subpaths not intended as public API
- vendor-specific convenience helpers
- setup/onboarding helpers that are implementation details
plugin-sdk/feishu, plugin-sdk/feishu-setup, plugin-sdk/zalo,
plugin-sdk/zalo-setup, and several plugin-sdk/matrix* seams. Treat those as
reserved implementation-detail exports, not as the recommended SDK pattern for
new third-party plugins.
Load pipeline
At startup, Velaclaw does roughly this:- discover candidate plugin roots
- read native or compatible bundle manifests and package metadata
- reject unsafe candidates
- normalize plugin config (
plugins.enabled,allow,deny,entries,slots,load.paths) - decide enablement for each candidate
- load enabled native modules via jiti
- call native
register(api)(oractivate(api)— a legacy alias) hooks and collect registrations into the plugin registry - expose the registry to commands/runtime surfaces
activate is a legacy alias for register — the loader resolves whichever is present (def.register ?? def.activate) and calls it at the same point. All bundled plugins use register; prefer register for new plugins.Manifest-first behavior
The manifest is the control-plane source of truth. Velaclaw uses it to:- identify the plugin
- discover declared channels/skills/config schema or bundle capabilities
- validate
plugins.entries.<id>.config - augment Control UI labels/placeholders
- show install/catalog metadata
- preserve cheap activation and setup descriptors without loading plugin runtime
activation and setup blocks stay on the control plane.
They are metadata-only descriptors for activation planning and setup discovery;
they do not replace runtime registration, register(...), or setupEntry.
The first live activation consumers now use manifest command, channel, and provider hints
to narrow plugin loading before broader registry materialization:
- CLI loading narrows to plugins that own the requested primary command
- channel setup/plugin resolution narrows to plugins that own the requested channel id
- explicit provider setup/runtime resolution narrows to plugins that own the requested provider id
setup.providers and
setup.cliBackends to narrow candidate plugins before it falls back to
setup-api for plugins that still need setup-time runtime hooks. If more than
one discovered plugin claims the same normalized setup provider or CLI backend
id, setup lookup refuses the ambiguous owner instead of relying on discovery
order.
What the loader caches
Velaclaw keeps short in-process caches for:- discovery results
- manifest registry data
- loaded plugin registries
- Set
VELACLAW_DISABLE_PLUGIN_DISCOVERY_CACHE=1orVELACLAW_DISABLE_PLUGIN_MANIFEST_CACHE=1to disable these caches. - Tune cache windows with
VELACLAW_PLUGIN_DISCOVERY_CACHE_MSandVELACLAW_PLUGIN_MANIFEST_CACHE_MS.
Registry model
Loaded plugins do not directly mutate random core globals. They register into a central plugin registry. The registry tracks:- plugin records (identity, source, origin, status, diagnostics)
- tools
- legacy hooks and typed hooks
- channels
- providers
- gateway RPC handlers
- HTTP routes
- CLI registrars
- background services
- plugin-owned commands
- plugin module -> registry registration
- core runtime -> registry consumption
Conversation binding callbacks
Plugins that bind a conversation can react when an approval is resolved. Useapi.onConversationBindingResolved(...) to receive a callback after a bind
request is approved or denied:
status:"approved"or"denied"decision:"allow-once","allow-always", or"deny"binding: the resolved binding for approved requestsrequest: the original request summary, detach hint, sender id, and conversation metadata
Provider runtime hooks
Provider plugins now have two layers:- manifest metadata:
providerAuthEnvVarsfor cheap provider env-auth lookup before runtime load,providerAuthAliasesfor provider variants that share auth,channelEnvVarsfor cheap channel env/setup lookup before runtime load, plusproviderAuthChoicesfor cheap onboarding/auth-choice labels and CLI flag metadata before runtime load - config-time hooks:
catalog/ legacydiscoveryplusapplyConfigDefaults - runtime hooks:
normalizeModelId,normalizeTransport,normalizeConfig,applyNativeStreamingUsageCompat,resolveConfigApiKey,resolveSyntheticAuth,resolveExternalAuthProfiles,shouldDeferSyntheticProfileAuth,resolveDynamicModel,prepareDynamicModel,normalizeResolvedModel,contributeResolvedModelCompat,capabilities,normalizeToolSchemas,inspectToolSchemas,resolveReasoningOutputMode,prepareExtraParams,createStreamFn,wrapStreamFn,resolveTransportTurnState,resolveWebSocketSessionPolicy,formatApiKey,refreshOAuth,buildAuthDoctorHint,matchesContextOverflowError,classifyFailoverReason,isCacheTtlEligible,buildMissingAuthMessage,suppressBuiltInModel,augmentModelCatalog,isBinaryThinking,supportsXHighThinking,resolveDefaultThinkingLevel,isModernModelRef,prepareRuntimeAuth,resolveUsageAuth,fetchUsageSnapshot,createEmbeddingProvider,buildReplayPolicy,sanitizeReplayHistory,validateReplayTurns,onModelSelected
providerAuthEnvVars when the provider has env-based credentials
that generic auth/status/model-picker paths should see without loading plugin
runtime. Use manifest providerAuthAliases when one provider id should reuse
another provider id’s env vars, auth profiles, config-backed auth, and API-key
onboarding choice. Use manifest providerAuthChoices when onboarding/auth-choice
CLI surfaces should know the provider’s choice id, group labels, and simple
one-flag auth wiring without loading provider runtime. Keep provider runtime
envVars for operator-facing hints such as onboarding labels or OAuth
client-id/client-secret setup vars.
Use manifest channelEnvVars when a channel has env-driven auth or setup that
generic shell-env fallback, config/status checks, or setup prompts should see
without loading channel runtime.
Hook order and usage
For model/provider plugins, Velaclaw calls hooks in this rough order. The “When to use” column is the quick decision guide.normalizeModelId, normalizeTransport, and normalizeConfig first check the
matched provider plugin, then fall through other hook-capable provider plugins
until one actually changes the model id or transport/config. That keeps
alias/compat provider shims working without requiring the caller to know which
bundled plugin owns the rewrite. If no provider hook rewrites a supported
Google-family config entry, the bundled Google config normalizer still applies
that compatibility cleanup.
If the provider needs a fully custom wire protocol or custom request executor,
that is a different class of extension. These hooks are for provider behavior
that still runs on Velaclaw’s normal inference loop.
Provider example
Built-in examples
- Anthropic uses
resolveDynamicModel,capabilities,buildAuthDoctorHint,resolveUsageAuth,fetchUsageSnapshot,isCacheTtlEligible,resolveDefaultThinkingLevel,applyConfigDefaults,isModernModelRef, andwrapStreamFnbecause it owns Claude 4.6 forward-compat, provider-family hints, auth repair guidance, usage endpoint integration, prompt-cache eligibility, auth-aware config defaults, Claude default/adaptive thinking policy, and Anthropic-specific stream shaping for beta headers,/fast/serviceTier, andcontext1m. - Anthropic’s Claude-specific stream helpers stay in the bundled plugin’s own
public
api.ts/contract-api.tsseam for now. That package surface exportswrapAnthropicProviderStream,resolveAnthropicBetas,resolveAnthropicFastMode,resolveAnthropicServiceTier, and the lower-level Anthropic wrapper builders instead of widening the generic SDK around one provider’s beta-header rules. - OpenAI uses
resolveDynamicModel,normalizeResolvedModel, andcapabilitiesplusbuildMissingAuthMessage,suppressBuiltInModel,augmentModelCatalog,supportsXHighThinking, andisModernModelRefbecause it owns GPT-5.4 forward-compat, the direct OpenAIopenai-completions->openai-responsesnormalization, Codex-aware auth hints, Spark suppression, synthetic OpenAI list rows, and GPT-5 thinking / live-model policy; theopenai-responses-defaultsstream family owns the shared native OpenAI Responses wrappers for attribution headers,/fast/serviceTier, text verbosity, native Codex web search, reasoning-compat payload shaping, and Responses context management. - OpenRouter uses
catalogplusresolveDynamicModelandprepareDynamicModelbecause the provider is pass-through and may expose new model ids before Velaclaw’s static catalog updates; it also usescapabilities,wrapStreamFn, andisCacheTtlEligibleto keep provider-specific request headers, routing metadata, reasoning patches, and prompt-cache policy out of core. Its replay policy comes from thepassthrough-geminifamily, while theopenrouter-thinkingstream family owns proxy reasoning injection and the unsupported-model /autoskips. - GitHub Copilot uses
catalog,auth,resolveDynamicModel, andcapabilitiesplusprepareRuntimeAuthandfetchUsageSnapshotbecause it needs provider-owned device login, model fallback behavior, Claude transcript quirks, a GitHub token -> Copilot token exchange, and a provider-owned usage endpoint. - OpenAI Codex uses
catalog,resolveDynamicModel,normalizeResolvedModel,refreshOAuth, andaugmentModelCatalogplusprepareExtraParams,resolveUsageAuth, andfetchUsageSnapshotbecause it still runs on core OpenAI transports but owns its transport/base URL normalization, OAuth refresh fallback policy, default transport choice, synthetic Codex catalog rows, and ChatGPT usage endpoint integration; it shares the sameopenai-responses-defaultsstream family as direct OpenAI. - Google AI Studio and Gemini CLI OAuth use
resolveDynamicModel,buildReplayPolicy,sanitizeReplayHistory,resolveReasoningOutputMode,wrapStreamFn, andisModernModelRefbecause thegoogle-geminireplay family owns Gemini 3.1 forward-compat fallback, native Gemini replay validation, bootstrap replay sanitation, tagged reasoning-output mode, and modern-model matching, while thegoogle-thinkingstream family owns Gemini thinking payload normalization; Gemini CLI OAuth also usesformatApiKey,resolveUsageAuth, andfetchUsageSnapshotfor token formatting, token parsing, and quota endpoint wiring. - Anthropic Vertex uses
buildReplayPolicythrough theanthropic-by-modelreplay family so Claude-specific replay cleanup stays scoped to Claude ids instead of everyanthropic-messagestransport. - Amazon Bedrock uses
buildReplayPolicy,matchesContextOverflowError,classifyFailoverReason, andresolveDefaultThinkingLevelbecause it owns Bedrock-specific throttle/not-ready/context-overflow error classification for Anthropic-on-Bedrock traffic; its replay policy still shares the same Claude-onlyanthropic-by-modelguard. - OpenRouter, Kilocode, Opencode, and Opencode Go use
buildReplayPolicythrough thepassthrough-geminireplay family because they proxy Gemini models through OpenAI-compatible transports and need Gemini thought-signature sanitation without native Gemini replay validation or bootstrap rewrites. - MiniMax uses
buildReplayPolicythrough thehybrid-anthropic-openaireplay family because one provider owns both Anthropic-message and OpenAI-compatible semantics; it keeps Claude-only thinking-block dropping on the Anthropic side while overriding reasoning output mode back to native, and theminimax-fast-modestream family owns fast-mode model rewrites on the shared stream path. - Moonshot uses
catalogpluswrapStreamFnbecause it still uses the shared OpenAI transport but needs provider-owned thinking payload normalization; themoonshot-thinkingstream family maps config plus/thinkstate onto its native binary thinking payload. - Kilocode uses
catalog,capabilities,wrapStreamFn, andisCacheTtlEligiblebecause it needs provider-owned request headers, reasoning payload normalization, Gemini transcript hints, and Anthropic cache-TTL gating; thekilocode-thinkingstream family keeps Kilo thinking injection on the shared proxy stream path while skippingkilo/autoand other proxy model ids that do not support explicit reasoning payloads. - Z.AI uses
resolveDynamicModel,prepareExtraParams,wrapStreamFn,isCacheTtlEligible,isBinaryThinking,isModernModelRef,resolveUsageAuth, andfetchUsageSnapshotbecause it owns GLM-5 fallback,tool_streamdefaults, binary thinking UX, modern-model matching, and both usage auth + quota fetching; thetool-stream-default-onstream family keeps the default-ontool_streamwrapper out of per-provider handwritten glue. - xAI uses
normalizeResolvedModel,normalizeTransport,contributeResolvedModelCompat,prepareExtraParams,wrapStreamFn,resolveSyntheticAuth,resolveDynamicModel, andisModernModelRefbecause it owns native xAI Responses transport normalization, Grok fast-mode alias rewrites, defaulttool_stream, strict-tool / reasoning-payload cleanup, fallback auth reuse for plugin-owned tools, forward-compat Grok model resolution, and provider-owned compat patches such as xAI tool-schema profile, unsupported schema keywords, nativeweb_search, and HTML-entity tool-call argument decoding. - Mistral, OpenCode Zen, and OpenCode Go use
capabilitiesonly to keep transcript/tooling quirks out of core. - Catalog-only bundled providers such as
byteplus,cloudflare-ai-gateway,huggingface,kimi-coding,nvidia,qianfan,synthetic,together,venice,vercel-ai-gateway, andvolcengineusecatalogonly. - Qwen uses
catalogfor its text provider plus shared media-understanding and video-generation registrations for its multimodal surfaces. - MiniMax and Xiaomi use
catalogplus usage hooks because their/usagebehavior is plugin-owned even though inference still runs through the shared transports.
Runtime helpers
Plugins can access selected core helpers viaapi.runtime. For TTS:
textToSpeechreturns the normal core TTS output payload for file/voice-note surfaces.- Uses core
messages.ttsconfiguration and provider selection. - Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
listVoicesis optional per provider. Use it for vendor-owned voice pickers or setup flows.- Voice listings can include richer metadata such as locale, gender, and personality tags for provider-aware pickers.
- OpenAI and ElevenLabs support telephony today. Microsoft does not.
api.registerSpeechProvider(...).
- Keep TTS policy, fallback, and reply delivery in core.
- Use speech providers for vendor-owned synthesis behavior.
- Legacy Microsoft
edgeinput is normalized to themicrosoftprovider id. - The preferred ownership model is company-oriented: one vendor plugin can own text, speech, image, and future media providers as Velaclaw adds those capability contracts.
- Keep orchestration, fallback, config, and channel wiring in core.
- Keep vendor behavior in the provider plugin.
- Additive expansion should stay typed: new optional methods, new optional result fields, new optional capabilities.
- Video generation already follows the same pattern:
- core owns the capability contract and runtime helper
- vendor plugins register
api.registerVideoGenerationProvider(...) - feature/channel plugins consume
api.runtime.videoGeneration.*
api.runtime.mediaUnderstanding.*is the preferred shared surface for image/audio/video understanding.- Uses core media-understanding audio configuration (
tools.media.audio) and provider fallback order. - Returns
{ text: undefined }when no transcription output is produced (for example skipped/unsupported input). api.runtime.stt.transcribeAudioFile(...)remains as a compatibility alias.
api.runtime.subagent:
providerandmodelare optional per-run overrides, not persistent session changes.- Velaclaw only honors those override fields for trusted callers.
- For plugin-owned fallback runs, operators must opt in with
plugins.entries.<id>.subagent.allowModelOverride: true. - Use
plugins.entries.<id>.subagent.allowedModelsto restrict trusted plugins to specific canonicalprovider/modeltargets, or"*"to allow any target explicitly. - Untrusted plugin subagent runs still work, but override requests are rejected instead of silently falling back.
api.registerWebSearchProvider(...).
Notes:
- Keep provider selection, credential resolution, and shared request semantics in core.
- Use web-search providers for vendor-specific search transports.
api.runtime.webSearch.*is the preferred shared surface for feature/channel plugins that need search behavior without depending on the agent tool wrapper.
api.runtime.imageGeneration
generate(...): generate an image using the configured image-generation provider chain.listProviders(...): list available image-generation providers and their capabilities.
Gateway HTTP routes
Plugins can expose HTTP endpoints withapi.registerHttpRoute(...).
path: route path under the gateway HTTP server.auth: required. Use"gateway"to require normal gateway auth, or"plugin"for plugin-managed auth/webhook verification.match: optional."exact"(default) or"prefix".replaceExisting: optional. Allows the same plugin to replace its own existing route registration.handler: returntruewhen the route handled the request.
api.registerHttpHandler(...)was removed and will cause a plugin-load error. Useapi.registerHttpRoute(...)instead.- Plugin routes must declare
authexplicitly. - Exact
path + matchconflicts are rejected unlessreplaceExisting: true, and one plugin cannot replace another plugin’s route. - Overlapping routes with different
authlevels are rejected. Keepexact/prefixfallthrough chains on the same auth level only. auth: "plugin"routes do not receive operator runtime scopes automatically. They are for plugin-managed webhooks/signature verification, not privileged Gateway helper calls.auth: "gateway"routes run inside a Gateway request runtime scope, but that scope is intentionally conservative:- shared-secret bearer auth (
gateway.auth.mode = "token"/"password") keeps plugin-route runtime scopes pinned tooperator.write, even if the caller sendsx-velaclaw-scopes - trusted identity-bearing HTTP modes (for example
trusted-proxyorgateway.auth.mode = "none"on a private ingress) honorx-velaclaw-scopesonly when the header is explicitly present - if
x-velaclaw-scopesis absent on those identity-bearing plugin-route requests, runtime scope falls back tooperator.write
- shared-secret bearer auth (
- Practical rule: do not assume a gateway-auth plugin route is an implicit admin surface. If your route needs admin-only behavior, require an identity-bearing auth mode and document the explicit
x-velaclaw-scopesheader contract.
Plugin SDK import paths
Use SDK subpaths instead of the monolithicvelaclaw/plugin-sdk import when
authoring plugins:
velaclaw/plugin-sdk/plugin-entryfor plugin registration primitives.velaclaw/plugin-sdk/corefor the generic shared plugin-facing contract.velaclaw/plugin-sdk/config-schemafor the rootvelaclaw.jsonZod schema export (VelaclawSchema).- Stable channel primitives such as
velaclaw/plugin-sdk/channel-setup,velaclaw/plugin-sdk/setup-runtime,velaclaw/plugin-sdk/setup-adapter-runtime,velaclaw/plugin-sdk/setup-tools,velaclaw/plugin-sdk/channel-pairing,velaclaw/plugin-sdk/channel-contract,velaclaw/plugin-sdk/channel-feedback,velaclaw/plugin-sdk/channel-inbound,velaclaw/plugin-sdk/channel-lifecycle,velaclaw/plugin-sdk/channel-reply-pipeline,velaclaw/plugin-sdk/command-auth,velaclaw/plugin-sdk/secret-input, andvelaclaw/plugin-sdk/webhook-ingressfor shared setup/auth/reply/webhook wiring.channel-inboundis the shared home for debounce, mention matching, inbound mention-policy helpers, envelope formatting, and inbound envelope context helpers.channel-setupis the narrow optional-install setup seam.setup-runtimeis the runtime-safe setup surface used bysetupEntry/ deferred startup, including the import-safe setup patch adapters.setup-adapter-runtimeis the env-aware account-setup adapter seam.setup-toolsis the small CLI/archive/docs helper seam (formatCliCommand,detectBinary,extractArchive,resolveBrewExecutable,formatDocsLink,CONFIG_DIR). - Domain subpaths such as
velaclaw/plugin-sdk/channel-config-helpers,velaclaw/plugin-sdk/allow-from,velaclaw/plugin-sdk/channel-config-schema,velaclaw/plugin-sdk/telegram-command-config,velaclaw/plugin-sdk/channel-policy,velaclaw/plugin-sdk/approval-gateway-runtime,velaclaw/plugin-sdk/approval-handler-adapter-runtime,velaclaw/plugin-sdk/approval-handler-runtime,velaclaw/plugin-sdk/approval-runtime,velaclaw/plugin-sdk/config-runtime,velaclaw/plugin-sdk/infra-runtime,velaclaw/plugin-sdk/agent-runtime,velaclaw/plugin-sdk/lazy-runtime,velaclaw/plugin-sdk/reply-history,velaclaw/plugin-sdk/routing,velaclaw/plugin-sdk/status-helpers,velaclaw/plugin-sdk/text-runtime,velaclaw/plugin-sdk/runtime-store, andvelaclaw/plugin-sdk/directory-runtimefor shared runtime/config helpers.telegram-command-configis the narrow public seam for Telegram custom command normalization/validation and stays available even if the bundled Telegram contract surface is temporarily unavailable.text-runtimeis the shared text/markdown/logging seam, including assistant-visible-text stripping, markdown render/chunking helpers, redaction helpers, directive-tag helpers, and safe-text utilities. - Approval-specific channel seams should prefer one
approvalCapabilitycontract on the plugin. Core then reads approval auth, delivery, render, native-routing, and lazy native-handler behavior through that one capability instead of mixing approval behavior into unrelated plugin fields. velaclaw/plugin-sdk/channel-runtimeis deprecated and remains only as a compatibility shim for older plugins. New code should import the narrower generic primitives instead, and repo code should not add new imports of the shim.- Bundled extension internals remain private. External plugins should use only
velaclaw/plugin-sdk/*subpaths. Velaclaw core/test code may use the repo public entry points under a plugin package root such asindex.js,api.js,runtime-api.js,setup-entry.js, and narrowly scoped files such aslogin-qr-api.js. Never import a plugin package’ssrc/*from core or from another extension. - Repo entry point split:
<plugin-package-root>/api.jsis the helper/types barrel,<plugin-package-root>/runtime-api.jsis the runtime-only barrel,<plugin-package-root>/index.jsis the bundled plugin entry, and<plugin-package-root>/setup-entry.jsis the setup plugin entry. - Current bundled provider examples:
- Anthropic uses
api.js/contract-api.jsfor Claude stream helpers such aswrapAnthropicProviderStream, beta-header helpers, andservice_tierparsing. - OpenAI uses
api.jsfor provider builders, default-model helpers, and realtime provider builders. - OpenRouter uses
api.jsfor its provider builder plus onboarding/config helpers, whileregister.runtime.jscan still re-export genericplugin-sdk/provider-streamhelpers for repo-local use.
- Anthropic uses
- Facade-loaded public entry points prefer the active runtime config snapshot when one exists, then fall back to the resolved config file on disk when Velaclaw is not yet serving a runtime snapshot.
- Generic shared primitives remain the preferred public SDK contract. A small
reserved compatibility set of bundled channel-branded helper seams still
exists. Treat those as bundled-maintenance/compatibility seams, not new
third-party import targets; new cross-channel contracts should still land on
generic
plugin-sdk/*subpaths or the plugin-localapi.js/runtime-api.jsbarrels.
- Avoid the root
velaclaw/plugin-sdkbarrel for new code. - Prefer the narrow stable primitives first. The newer setup/pairing/reply/
feedback/contract/inbound/threading/command/secret-input/webhook/infra/
allowlist/status/message-tool subpaths are the intended contract for new
bundled and external plugin work.
Target parsing/matching belongs on
velaclaw/plugin-sdk/channel-targets. Message action gates and reaction message-id helpers belong onvelaclaw/plugin-sdk/channel-actions. - Bundled extension-specific helper barrels are not stable by default. If a
helper is only needed by a bundled extension, keep it behind the extension’s
local
api.jsorruntime-api.jsseam instead of promoting it intovelaclaw/plugin-sdk/<extension>. - New shared helper seams should be generic, not channel-branded. Shared target
parsing belongs on
velaclaw/plugin-sdk/channel-targets; channel-specific internals stay behind the owning plugin’s localapi.jsorruntime-api.jsseam. - Capability-specific subpaths such as
image-generation,media-understanding, andspeechexist because bundled/native plugins use them today. Their presence does not by itself mean every exported helper is a long-term frozen external contract.
Message tool schemas
Plugins should own channel-specificdescribeMessageTool(...) schema
contributions. Keep provider-specific fields in the plugin, not in shared core.
For shared portable schema fragments, reuse the generic helpers exported through
velaclaw/plugin-sdk/channel-actions:
createMessageToolButtonsSchema()for button-grid style payloadscreateMessageToolCardSchema()for structured card payloads
Channel target resolution
Channel plugins should own channel-specific target semantics. Keep the shared outbound host generic and use the messaging adapter surface for provider rules:messaging.inferTargetChatType({ to })decides whether a normalized target should be treated asdirect,group, orchannelbefore directory lookup.messaging.targetResolver.looksLikeId(raw, normalized)tells core whether an input should skip straight to id-like resolution instead of directory search.messaging.targetResolver.resolveTarget(...)is the plugin fallback when core needs a final provider-owned resolution after normalization or after a directory miss.messaging.resolveOutboundSessionRoute(...)owns provider-specific session route construction once a target is resolved.
- Use
inferTargetChatTypefor category decisions that should happen before searching peers/groups. - Use
looksLikeIdfor “treat this as an explicit/native target id” checks. - Use
resolveTargetfor provider-specific normalization fallback, not for broad directory search. - Keep provider-native ids like chat ids, thread ids, JIDs, handles, and room
ids inside
targetvalues or provider-specific params, not in generic SDK fields.
Config-backed directories
Plugins that derive directory entries from config should keep that logic in the plugin and reuse the shared helpers fromvelaclaw/plugin-sdk/directory-runtime.
Use this when a channel needs config-backed peers/groups such as:
- allowlist-driven DM peers
- configured channel/group maps
- account-scoped static directory fallbacks
directory-runtime only handle generic operations:
- query filtering
- limit application
- deduping/normalization helpers
- building
ChannelDirectoryEntry[]
Provider catalogs
Provider plugins can define model catalogs for inference withregisterProvider({ catalog: { run(...) { ... } } }).
catalog.run(...) returns the same shape Velaclaw writes into
models.providers:
{ provider }for one provider entry{ providers }for multiple provider entries
catalog when the plugin owns provider-specific model ids, base URL
defaults, or auth-gated model metadata.
catalog.order controls when a plugin’s catalog merges relative to Velaclaw’s
built-in implicit providers:
simple: plain API-key or env-driven providersprofile: providers that appear when auth profiles existpaired: providers that synthesize multiple related provider entrieslate: last pass, after other implicit providers
discoverystill works as a legacy alias- if both
cataloganddiscoveryare registered, Velaclaw usescatalog
Read-only channel inspection
If your plugin registers a channel, prefer implementingplugin.config.inspectAccount(cfg, accountId) alongside resolveAccount(...).
Why:
resolveAccount(...)is the runtime path. It is allowed to assume credentials are fully materialized and can fail fast when required secrets are missing.- Read-only command paths such as
velaclaw status,velaclaw status --all,velaclaw channels status,velaclaw channels resolve, and doctor/config repair flows should not need to materialize runtime credentials just to describe configuration.
inspectAccount(...) behavior:
- Return descriptive account state only.
- Preserve
enabledandconfigured. - Include credential source/status fields when relevant, such as:
tokenSource,tokenStatusbotTokenSource,botTokenStatusappTokenSource,appTokenStatussigningSecretSource,signingSecretStatus
- You do not need to return raw token values just to report read-only
availability. Returning
tokenStatus: "available"(and the matching source field) is enough for status-style commands. - Use
configured_unavailablewhen a credential is configured via SecretRef but unavailable in the current command path.
Package packs
A plugin directory may include apackage.json with velaclaw.extensions:
name/<fileBase>.
If your plugin imports npm deps, install them in that directory so
node_modules is available (npm install / pnpm install).
Security guardrail: every velaclaw.extensions entry must stay inside the plugin
directory after symlink resolution. Entries that escape the package directory are
rejected.
Security note: velaclaw plugins install installs plugin dependencies with
npm install --omit=dev --ignore-scripts (no lifecycle scripts, no dev dependencies at runtime). Keep plugin dependency
trees “pure JS/TS” and avoid packages that require postinstall builds.
Optional: velaclaw.setupEntry can point at a lightweight setup-only module.
When Velaclaw needs setup surfaces for a disabled channel plugin, or
when a channel plugin is enabled but still unconfigured, it loads setupEntry
instead of the full plugin entry. This keeps startup and setup lighter
when your main plugin entry also wires tools, hooks, or other runtime-only
code.
Optional: velaclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen
can opt a channel plugin into the same setupEntry path during the gateway’s
pre-listen startup phase, even when the channel is already configured.
Use this only when setupEntry fully covers the startup surface that must exist
before the gateway starts listening. In practice, that means the setup entry
must register every channel-owned capability that startup depends on, such as:
- channel registration itself
- any HTTP routes that must be available before the gateway starts listening
- any gateway methods, tools, or services that must exist during that same window
singleAccountKeysToMovenamedAccountPromotionKeysresolveSingleAccountPromotionTarget(...)
channels.<id>.accounts.* without loading the full plugin entry.
Matrix is the current bundled example: it moves only auth/bootstrap keys into a
named promoted account when named accounts already exist, and it can preserve a
configured non-canonical default-account key instead of always creating
accounts.default.
Those setup patch adapters keep bundled contract-surface discovery lazy. Import
time stays light; the promotion surface is loaded only on first use instead of
re-entering bundled channel startup on module import.
When those startup surfaces include gateway RPC methods, keep them on a
plugin-specific prefix. Core admin namespaces (config.*,
exec.approvals.*, wizard.*, update.*) remain reserved and always resolve
to operator.admin, even if a plugin requests a narrower scope.
Example:
Channel catalog metadata
Channel plugins can advertise setup/discovery metadata viavelaclaw.channel and
install hints via velaclaw.install. This keeps the core catalog data-free.
Example:
velaclaw.channel fields beyond the minimal example:
detailLabel: secondary label for richer catalog/status surfacesdocsLabel: override link text for the docs linkpreferOver: lower-priority plugin/channel ids this catalog entry should outrankselectionDocsPrefix,selectionDocsOmitLabel,selectionExtras: selection-surface copy controlsmarkdownCapable: marks the channel as markdown-capable for outbound formatting decisionsexposure.configured: hide the channel from configured-channel listing surfaces when set tofalseexposure.setup: hide the channel from interactive setup/configure pickers when set tofalseexposure.docs: mark the channel as internal/private for docs navigation surfacesshowConfigured/showInSetup: legacy aliases still accepted for compatibility; preferexposurequickstartAllowFrom: opt the channel into the standard quickstartallowFromflowforceAccountBinding: require explicit account binding even when only one account existspreferSessionLookupForAnnounceTarget: prefer session lookup when resolving announce targets
~/.velaclaw/mpm/plugins.json~/.velaclaw/mpm/catalog.json~/.velaclaw/plugins/catalog.json
VELACLAW_PLUGIN_CATALOG_PATHS (or VELACLAW_MPM_CATALOG_PATHS) at
one or more JSON files (comma/semicolon/PATH-delimited). Each file should
contain { "entries": [ { "name": "@scope/pkg", "velaclaw": { "channel": {...}, "install": {...} } } ] }. The parser also accepts "packages" or "plugins" as legacy aliases for the "entries" key.
Context engine plugins
Context engine plugins own session context orchestration for ingest, assembly, and compaction. Register them from your plugin withapi.registerContextEngine(id, factory), then select the active engine with
plugins.slots.contextEngine.
Use this when your plugin needs to replace or extend the default context
pipeline rather than just add memory search or hooks.
compact()
implemented and delegate it explicitly:
Adding a new capability
When a plugin needs behavior that does not fit the current API, do not bypass the plugin system with a private reach-in. Add the missing capability. Recommended sequence:- define the core contract Decide what shared behavior core should own: policy, fallback, config merge, lifecycle, channel-facing semantics, and runtime helper shape.
- add typed plugin registration/runtime surfaces
Extend
VelaclawPluginApiand/orapi.runtimewith the smallest useful typed capability surface. - wire core + channel/feature consumers Channels and feature plugins should consume the new capability through core, not by importing a vendor implementation directly.
- register vendor implementations Vendor plugins then register their backends against the capability.
- add contract coverage Add tests so ownership and registration shape stay explicit over time.
Capability checklist
When you add a new capability, the implementation should usually touch these surfaces together:- core contract types in
src/<capability>/types.ts - core runner/runtime helper in
src/<capability>/runtime.ts - plugin API registration surface in
src/plugins/types.ts - plugin registry wiring in
src/plugins/registry.ts - plugin runtime exposure in
src/plugins/runtime/*when feature/channel plugins need to consume it - capture/test helpers in
src/test-utils/plugin-registration.ts - ownership/contract assertions in
src/plugins/contracts/registry.ts - operator/plugin docs in
docs/
Capability template
Minimal pattern:- core owns the capability contract + orchestration
- vendor plugins own vendor implementations
- feature/channel plugins consume runtime helpers
- contract tests keep ownership explicit