Building Provider Plugins for OpenClaw: Step-by-Step Guide
Learn to build a provider plugin that adds an LLM model provider to OpenClaw, covering model catalog, API-key auth, and dynamic model resolution. For developers new to OpenClaw plugins.
Read this when
- You are building a new model provider plugin
- You want to add an OpenAI-compatible proxy or custom LLM to OpenClaw
- You need to understand provider auth, catalogs, and runtime hooks
Build a provider plugin to add an LLM model provider to OpenClaw. The plugin covers a model catalog, API-key authentication, and dynamic model resolution.
Info
Are you new to OpenClaw plugins? Start with Getting Started to learn about package structure and manifest setup.
Tip
Provider plugins bring models into OpenClaw's standard inference loop. If the model has to run through a native agent daemon that manages threads, compaction, or tool events, use an agent harness alongside the provider instead of embedding daemon protocol details in core.
Walkthrough
Package and manifest
Step 1: Package and manifest
{
"name": "@myorg/openclaw-acme-ai",
"version": "1.0.0",
"type": "module",
"openclaw": {
"extensions": ["./index.ts"],
"providers": ["acme-ai"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
}
}
{
"id": "acme-ai",
"name": "Acme AI",
"description": "Acme AI model provider",
"providers": ["acme-ai"],
"modelSupport": {
"modelPrefixes": ["acme-"]
},
"setup": {
"providers": [
{
"id": "acme-ai",
"envVars": ["ACME_AI_API_KEY"]
}
]
},
"providerAuthAliases": {
"acme-ai-coding": "acme-ai"
},
"providerAuthChoices": [
{
"provider": "acme-ai",
"method": "api-key",
"choiceId": "acme-ai-api-key",
"choiceLabel": "Acme AI API key",
"groupId": "acme-ai",
"groupLabel": "Acme AI",
"cliFlag": "--acme-ai-api-key",
"cliOption": "--acme-ai-api-key <key>",
"cliDescription": "Acme AI API key"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false
}
}
OpenClaw can detect credentials without loading your plugin runtime thanks to setup.providers[].envVars. Add providerAuthAliases when a provider variant should reuse another provider id's auth. modelSupport is optional and enables OpenClaw to auto-load your provider plugin from shorthand model ids like acme-large before runtime hooks exist. For ClawHub publishing, openclaw.compat and openclaw.build in package.json are mandatory (openclaw.compat.pluginApi and openclaw.build.openclawVersion are the two required fields; minGatewayVersion defaults to openclaw.install.minHostVersion when not provided).
Register the provider
A minimal text provider requires an id, label, auth, and catalog. catalog is the provider-owned runtime/config hook; it can call live vendor APIs and returns models.providers entries.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
export default definePluginEntry({
id: "acme-ai",
name: "Acme AI",
description: "Acme AI model provider",
register(api) {
api.registerProvider({
id: "acme-ai",
label: "Acme AI",
docsPath: "/providers/acme-ai",
envVars: ["ACME_AI_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: "acme-ai",
methodId: "api-key",
label: "Acme AI API key",
hint: "API key from your Acme AI dashboard",
optionKey: "acmeAiApiKey",
flagName: "--acme-ai-api-key",
envVar: "ACME_AI_API_KEY",
promptMessage: "Enter your Acme AI API key",
defaultModel: "acme-ai/acme-large",
}),
],
catalog: {
order: "simple",
run: async (ctx) => {
const apiKey =
ctx.resolveProviderApiKey("acme-ai").apiKey;
if (!apiKey) return null;
return {
provider: {
baseUrl: "https://api.acme-ai.com/v1",
apiKey,
api: "openai-completions",
models: [
{
id: "acme-large",
name: "Acme Large",
reasoning: true,
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 32768,
},
{
id: "acme-small",
name: "Acme Small",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
contextWindow: 128000,
maxTokens: 8192,
},
],
},
};
},
},
});
api.registerModelCatalogProvider({
provider: "acme-ai",
kinds: ["text"],
liveCatalog: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey("acme-ai").apiKey;
if (!apiKey) return null;
return [
{
kind: "text",
provider: "acme-ai",
model: "acme-large",
label: "Acme Large",
source: "live",
},
];
},
});
},
});
registerModelCatalogProvider is the newer control-plane catalog surface for list/help/picker UI, covering text, voice, image_generation, video_generation, and music_generation rows. Keep vendor endpoint calls and response mapping in the plugin; OpenClaw owns the shared row shape, source labels, and help rendering.
That is a working provider. Users can now run openclaw onboard --acme-ai-api-key <key> and select acme-ai/acme-large as their model.
Live model discovery
If your provider exposes an OpenAI-compatible /models API, opt the single-provider helper into shared discovery:
catalog: {
buildProvider: () => ({
api: "openai-completions",
baseUrl: "https://api.acme-ai.com/v1",
models: [...STATIC_MODELS],
}),
buildStaticProvider: () => ({
api: "openai-completions",
baseUrl: "https://api.acme-ai.com/v1",
models: [...STATIC_MODELS],
}),
liveModelDiscovery: true,
},
liveModelDiscovery: true is a public Plugin SDK contract with these behaviors:
| Area | Contract |
|---|---|
| Credentials | Discovery uses the catalog's resolved provider credential, preferring discoveryApiKey when auth supplies one. Secret-reference markers are never sent as tokens. The default request uses Authorization: Bearer <token>; use buildRequestHeaders for another vendor auth scheme. |
| Endpoint | The default URL is models relative to the effective provider baseUrl, including an operator override when allowExplicitBaseUrl is enabled. Use endpointPath for another relative path. Use endpointUrl: { url, requireBaseUrl } only for a fixed vendor URL; discovery is skipped unless the effective base URL still equals requireBaseUrl, so a custom proxy credential is not sent to the vendor. |
| Network limits | Fetches use OpenClaw's SSRF guard, one 5-second timeout budget across pagination, a 4 MiB response limit per page, and a 50-page limit. Cross-origin pagination links are rejected; credentials are removed after a cross-origin redirect. |
| Cache | Successful, non-empty catalogs are cached for 60 seconds by provider, endpoint, and resolved credential. Empty or unusable results are not cached. |
| Filtering | Exact live IDs keep their trusted static metadata. New rows are projected conservatively as text/chat models. Disabled, archived, deprecated, explicitly non-chat, embedding, reranking, moderation, speech, image-only, and video-only rows are excluded. Use readRows only to select rows from a nonstandard response envelope; provider-specific model semantics still belong in a custom catalog. |
| Admission | Optional. Set acceptUnknownModel: ({ id, record }) => boolean when your request shaping is model-version specific, so discovery cannot publish a model you cannot yet build a valid request for. It is called only for IDs your static catalog does not already publish; known IDs bypass it and keep their published metadata. Return false to drop the row. Providers that omit it keep the previous behavior unchanged. Prefer comparing the vendor's advertised capabilities against your own contract checks over a hand-maintained model list, and fail closed when the row carries no capability data. |
| Failure | Live discovery is advisory. Auth, network, timeout, pagination, parsing, empty-catalog, and filtering failures return the provider-owned static seed instead of removing the provider. |
For a non-Bearer or nonstandard list endpoint, pass options instead of true:
liveModelDiscovery: {
endpointPath: "model-catalog",
buildRequestHeaders: ({ apiKey, discoveryApiKey }) => ({
"vendor-version": "2026-01-01",
"x-api-key": discoveryApiKey ?? apiKey ?? "",
}),
readRows: (body) =>
body && typeof body === "object" &&
Array.isArray((body as { models?: unknown }).models)
? (body as { models: unknown[] }).models
: [],
},
Do not use endpointUrl as an unconditional alternate host. Its requireBaseUrl check is the credential-isolation boundary for providers whose model-list host differs from their inference host.
If the provider needs custom model semantics rather than the conservative OpenAI-compatible projection, keep only that projection in the plugin. Pass it as projectRows; the shared runtime still owns guarded fetches, provider-auth headers, cache admission, and static fallback.
Use buildLiveModelProviderConfig when the live API only tells you which provider-owned static catalog rows are currently available:
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import {
buildLiveModelProviderConfig,
type LiveModelCatalogFetchGuard,
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
const STATIC_MODELS = [
{
id: "acme-large",
name: "Acme Large",
reasoning: true,
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 32768,
},
{
id: "acme-small",
name: "Acme Small",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
contextWindow: 128000,
maxTokens: 8192,
},
] as const;
async function buildAcmeLiveProvider(params: {
apiKey: string;
discoveryApiKey?: string;
fetchGuard?: LiveModelCatalogFetchGuard;
}) {
return await buildLiveModelProviderConfig({
providerId: "acme-ai",
endpoint: "https://api.acme-ai.com/v1/models",
providerConfig: {
baseUrl: "https://api.acme-ai.com/v1",
api: "openai-completions",
},
models: STATIC_MODELS,
apiKey: params.apiKey,
discoveryApiKey: params.discoveryApiKey,
fetchGuard: params.fetchGuard,
ttlMs: 60_000,
auditContext: "acme-ai-model-discovery",
projectRows: (rows, fallback) =>
rows.flatMap((row) => {
const model = projectAcmeModel(row, fallback);
return model ? [model] : [];
}),
});
}
export default definePluginEntry({
id: "acme-ai",
name: "Acme AI",
register(api) {
api.registerProvider({
id: "acme-ai",
label: "Acme AI",
catalog: {
order: "simple",
run: async (ctx) => {
const auth = ctx.resolveProviderAuth("acme-ai");
const apiKey =
auth.apiKey ?? ctx.resolveProviderApiKey("acme-ai").apiKey;
if (!apiKey) return null;
return {
provider: await buildAcmeLiveProvider({
apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
};
},
},
staticCatalog: {
order: "simple",
run: async () => ({
provider: {
baseUrl: "https://api.acme-ai.com/v1",
api: "openai-completions",
models: [...STATIC_MODELS],
},
}),
},
});
},
});
run must remain auth-gated and return null when no valid credential exists. Provide an offline staticRun or static fallback so setup, docs, tests, and picker surfaces never rely on live network access. Choose a TTL that fits model-list freshness, skip request-time filesystem polling, and pass a provider-specific readRows / readModelId only when the upstream response deviates from an OpenAI-compatible { data: [{ id, object }] } shape.
When the upstream provider uses control tokens different from OpenClaw's, apply a small bidirectional text transform rather than swapping the stream path:
api.registerTextTransforms({
input: [
{ from: /red basket/g, to: "blue basket" },
{ from: /paper ticket/g, to: "digital ticket" },
{ from: /left shelf/g, to: "right shelf" },
],
output: [
{ from: /blue basket/g, to: "red basket" },
{ from: /digital ticket/g, to: "paper ticket" },
{ from: /right shelf/g, to: "left shelf" },
],
});
Before transport, input rewrites the final system prompt and text message content. output rewrites assistant text deltas and final text ahead of OpenClaw parsing its own control markers or channel delivery.
For bundled providers registering a single text provider with API-key auth plus one catalog-backed runtime, the narrower defineSingleProviderPluginEntry(...) helper is preferred:
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
export default defineSingleProviderPluginEntry({
id: "acme-ai",
name: "Acme AI",
description: "Acme AI model provider",
provider: {
label: "Acme AI",
docsPath: "/providers/acme-ai",
auth: [
{
methodId: "api-key",
label: "Acme AI API key",
hint: "API key from your Acme AI dashboard",
optionKey: "acmeAiApiKey",
flagName: "--acme-ai-api-key",
envVar: "ACME_AI_API_KEY",
promptMessage: "Enter your Acme AI API key",
defaultModel: "acme-ai/acme-large",
},
],
catalog: {
buildProvider: () => ({
api: "openai-completions",
baseUrl: "https://api.acme-ai.com/v1",
models: [{ id: "acme-large", name: "Acme Large" }],
}),
buildStaticProvider: () => ({
api: "openai-completions",
baseUrl: "https://api.acme-ai.com/v1",
models: [{ id: "acme-large", name: "Acme Large" }],
}),
},
},
});
buildProvider serves as the live catalog path when OpenClaw resolves real provider auth. It can run provider-specific discovery. Use buildStaticProvider only for offline rows safe to show before auth is set; it must avoid credentials and network requests. OpenClaw's models list --all display currently runs static catalogs only for bundled provider plugins, with an empty config, empty env, and no agent/workspace paths.
If your auth flow also patches models.providers.*, aliases, and the agent default model during onboarding, turn to the preset helpers in openclaw/plugin-sdk/provider-onboard. The narrowest options are createDefaultModelPresetAppliers(...), createDefaultModelsPresetAppliers(...), and createModelCatalogPresetAppliers(...).
When a provider's native endpoint supports streamed usage blocks on the standard openai-completions transport, prefer the shared catalog helpers in openclaw/plugin-sdk/provider-catalog-shared over hardcoded provider-id checks. supportsNativeStreamingUsageCompat(...) and applyProviderNativeStreamingUsageCompat(...) derive support from the endpoint capability map, so native Moonshot/DashScope-style endpoints still opt in even with a custom provider id.
The live discovery examples above cover /models-style provider APIs. Keep that discovery inside catalog.run, gated on usable auth, and keep staticRun network-free for offline catalog generation.
Add dynamic model resolution
If your provider accepts arbitrary model IDs (like a proxy or router), add resolveDynamicModel:
api.registerProvider({
// ... id, label, auth, catalog from above
resolveDynamicModel: (ctx) => ({
id: ctx.modelId,
name: ctx.modelId,
provider: "acme-ai",
api: "openai-completions",
baseUrl: "https://api.acme-ai.com/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
}),
});
When resolving requires a network call, use prepareDynamicModel for async warm-up, and resolveDynamicModel runs again after it finishes.
Add runtime hooks (as needed)
Most providers only need catalog + resolveDynamicModel. Add hooks incrementally as your provider demands them.
Shared helper builders now handle the most common replay/tool-compat families, so plugins rarely need to wire each hook individually:
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import { buildProviderStreamFamilyHooks } from "openclaw/plugin-sdk/provider-stream";
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
const GOOGLE_FAMILY_HOOKS = {
...buildProviderReplayFamilyHooks({ family: "google-gemini" }),
...buildProviderStreamFamilyHooks("google-thinking"),
...buildProviderToolCompatFamilyHooks("gemini"),
};
api.registerProvider({
id: "acme-gemini-compatible",
// ...
...GOOGLE_FAMILY_HOOKS,
});
Current replay families:
| Family | What it wires in | Bundled examples |
|---|---|---|
openai-compatible | Shared OpenAI-style replay policy for OpenAI-compatible transports, including tool-call-id sanitation, assistant-first ordering fixes, and generic Gemini-turn validation where the transport needs it | moonshot, ollama, xai, zai |
anthropic-by-model | Claude-aware replay policy selected by modelId, so Anthropic-message transports only get Claude-specific thinking-block cleanup when the resolved model is actually a Claude id | amazon-bedrock |
native-anthropic-by-model | Same Claude-by-model policy as anthropic-by-model, plus tool-call-id sanitation and native Anthropic tool-use id preservation for transports that must keep vendor-native ids | anthropic-vertex, clawrouter |
google-gemini | Native Gemini replay policy plus bootstrap replay sanitation. The shared family keeps the text-output Gemini CLI on tagged reasoning; the direct google provider overrides resolveReasoningOutputMode to native because Gemini API thinking arrives as native thought parts. | google, google-gemini-cli |
passthrough-gemini | Gemini thought-signature sanitation for Gemini models running through OpenAI-compatible proxy transports; does not enable native Gemini replay validation or bootstrap rewrites | openrouter, kilocode, opencode, opencode-go |
hybrid-anthropic-openai | Hybrid policy for providers that mix Anthropic-message and OpenAI-compatible model surfaces in one plugin; optional Claude-only thinking-block dropping stays scoped to the Anthropic side | minimax |
Available stream families at this time:
| Family | What it wires in | Bundled examples |
|---|---|---|
google-thinking | Gemini thinking payload normalization on the shared stream path | google, google-gemini-cli |
kilocode-thinking | Kilo reasoning wrapper on the shared proxy stream path, with kilo-auto/balanced and unsupported proxy reasoning ids skipping injected thinking | kilocode |
moonshot-thinking | Moonshot binary native-thinking payload mapping from config + /think level | moonshot |
minimax-fast-mode | MiniMax fast-mode model rewrite on the shared stream path | minimax, minimax-portal |
openai-responses-defaults | Shared native OpenAI/Codex Responses wrappers: attribution headers, /fast/serviceTier, text verbosity, native Codex web search, reasoning-compat payload shaping, and Responses context management | openai |
openrouter-thinking | OpenRouter reasoning wrapper for proxy routes, with unsupported-model/auto skips handled centrally | openrouter |
tool-stream-default-on | Default-on tool_stream wrapper for providers like Z.AI that want tool streaming unless explicitly disabled | zai |
SDK seams powering the family builders
Every family builder relies on lower-level public helpers exported from the same package, which you can use when a provider needs to deviate from the standard pattern:
openclaw/plugin-sdk/provider-model-shared-ProviderReplayFamily,buildProviderReplayFamilyHooks(...), and the raw replay builders (buildOpenAICompatibleReplayPolicy,buildAnthropicReplayPolicyForModel,buildGoogleGeminiReplayPolicy,buildHybridAnthropicOrOpenAIReplayPolicy). Also exports Gemini replay helpers (sanitizeGoogleGeminiReplayHistory,resolveTaggedReasoningOutputMode) and endpoint/model helpers (resolveProviderEndpoint,normalizeProviderId,normalizeGooglePreviewModelId).openclaw/plugin-sdk/provider-stream-ProviderStreamFamily,buildProviderStreamFamilyHooks(...),composeProviderStreamWrappers(...), plus the shared OpenAI/Codex wrappers (createOpenAIAttributionHeadersWrapper,createOpenAIFastModeWrapper,createOpenAIServiceTierWrapper,createOpenAIResponsesContextManagementWrapper,createCodexNativeWebSearchWrapper), DeepSeek V4 OpenAI-compatible wrapper (createDeepSeekV4OpenAICompatibleThinkingWrapper), Anthropic Messages thinking prefill cleanup (createAnthropicThinkingPrefillPayloadWrapper), plain-text tool-call compat (createPlainTextToolCallCompatWrapper), and shared proxy/provider wrappers (createOpenRouterWrapper,createToolStreamWrapper,createMinimaxFastModeWrapper).openclaw/plugin-sdk/provider-stream-shared- lightweight payload and event wrappers for hot provider paths, includingcreateOpenAICompatibleCompletionsThinkingOffWrapper,createPayloadPatchStreamWrapper,createPlainTextToolCallCompatWrapper,normalizeOpenAICompatibleReasoningPayload(...), andsetQwenChatTemplateThinking(...).openclaw/plugin-sdk/provider-tools-ProviderToolCompatFamily,buildProviderToolCompatFamilyHooks("deepseek" | "gemini" | "openai"), and underlying provider schema helpers.
For Gemini-family providers, the reasoning-output mode must match the transport in use. Providers that talk directly to the Google Gemini API should set native as their reasoning output, letting OpenClaw consume native thought parts without injecting <think> / <final> prompt directives. CLI-style Gemini backends that only handle text and parse a final JSON/text response can stick with the shared google-gemini tagged contract.
A few stream helpers are intentionally kept within individual providers. @openclaw/anthropic-provider exposes wrapAnthropicProviderStream, resolveAnthropicBetas, resolveAnthropicFastMode, resolveAnthropicServiceTier, and the lower-level Anthropic wrapper builders through its own public api.ts / contract-api.ts seam, since those encode Claude OAuth beta handling and context1m gating. The xAI plugin does the same with native xAI Responses shaping inside its own wrapStreamFn (/fast aliases, default tool_stream, unsupported strict-tool cleanup, xAI-specific reasoning-payload removal).
The same package-root pattern is what backs @openclaw/openai-provider (provider builders, default-model helpers, realtime provider builders) and @openclaw/openrouter-provider (provider builder plus onboarding/config helpers).
Token exchange
For providers that require a token exchange before every inference call:
prepareRuntimeAuth: async (ctx) => {
const exchanged = await exchangeToken(ctx.apiKey);
return {
apiKey: exchanged.token,
baseUrl: exchanged.baseUrl,
expiresAt: exchanged.expiresAt,
};
},
Custom headers
For providers that require custom request headers or body modifications:
// wrapStreamFn returns a StreamFn derived from ctx.streamFn
wrapStreamFn: (ctx) => {
if (!ctx.streamFn) return undefined;
const inner = ctx.streamFn;
return async (params) => {
params.headers = {
...params.headers,
"X-Acme-Version": "2",
};
return inner(params);
};
},
Native transport identity
For providers that require native request/session headers or metadata on generic HTTP or WebSocket transports:
resolveTransportTurnState: (ctx) => ({
headers: {
"x-request-id": ctx.turnId,
},
metadata: {
session_id: ctx.sessionId ?? "",
turn_id: ctx.turnId,
},
websocket: {
headers: {
"x-session-id": ctx.sessionId ?? "",
},
degradeCooldownMs: 60_000,
},
}),
The older resolveWebSocketSessionPolicy hook is still supported but deprecated. Move its fields under resolveTransportTurnState.websocket; during migration, fields from the new hook take precedence.
Usage and billing
For providers that expose usage/billing data:
resolveUsageAuth: async (ctx) => {
const auth = await ctx.resolveOAuthToken();
return auth ? { token: auth.token } : null;
},
fetchUsageSnapshot: async (ctx) => {
return await fetchAcmeUsage(ctx.token, ctx.timeoutMs);
},
resolveUsageAuth has three possible outcomes. Return { token, accountId?, subscriptionType?, rateLimitTier? } when the provider holds a usage/billing credential (the optional fields carry non-secret plan metadata from the resolved profile into fetchUsageSnapshot). Return { handled: true } only when the provider has definitively handled usage auth but has no usable usage token, and OpenClaw must skip generic API-key/OAuth fallback. Return null or undefined when the provider did not handle the request and OpenClaw should proceed with generic fallback.
Declare the provider id in contracts.usageProviders. When that manifest contract and both hooks are present, OpenClaw automatically includes the provider in usage collection without loading unrelated provider plugins. No core allowlist update is needed. fetchUsageSnapshot returns the shared provider-neutral shape:
plan: provider-reported subscription or key labelwindows: resettable quota windows as used percentagesbilling: typedbalance,spend, orbudgetentries;unitcan be an ISO currency or a provider unit such ascreditssummary: compact provider-specific context that does not fit those structured fields
Keep currency semantics exact. A provider credit is not USD unless the upstream contract says so. A plugin that implements only fetchUsageSnapshot remains available for explicit/synthetic callers but is not auto-discovered, because OpenClaw cannot resolve its usage credential.
Common provider hooks
OpenClaw invokes hooks in roughly this order for model/provider plugins. Most providers only use 2-3. This is not the full ProviderPlugin contract, see Internals: Provider Runtime Hooks for the complete, currently-accurate hook list and fallback notes. Compatibility-only provider fields that OpenClaw no longer calls, such as ProviderPlugin.capabilities and suppressBuiltInModel, are not listed here.
| Hook | When to use |
|---|---|
catalog | Defaults for the model catalog or base URL |
applyConfigDefaults | Global defaults owned by the provider, applied while config is materialized |
normalizeModelId | Cleaning up legacy or preview model-id aliases prior to lookup |
normalizeTransport | Removing provider-family api / baseUrl before generic model assembly |
normalizeConfig | Normalizing models.providers.<id> configuration |
applyNativeStreamingUsageCompat | Rewriting config providers for native streaming-usage compatibility |
resolveConfigApiKey | Resolving auth via provider-owned env markers |
resolveSyntheticAuth | Synthetic auth backed by local, self-hosted, or config sources |
resolveExternalAuthProfiles | Overlaying provider-owned external auth profiles on CLI or app-managed credentials |
shouldDeferSyntheticProfileAuth | Lowering synthetic stored-profile placeholders behind env or config auth |
resolveDynamicModel | Accepting any upstream model ID |
prepareDynamicModel | Fetching metadata asynchronously before resolution |
normalizeResolvedModel | Rewriting transport before the runner executes |
normalizeToolSchemas | Cleaning up provider-owned tool schemas before registration |
inspectToolSchemas | Diagnostics for provider-owned tool schemas |
resolveReasoningOutputMode | Contract for tagged versus native reasoning output |
prepareExtraParams | Default request parameters |
createStreamFn | Fully custom StreamFn transport |
wrapStreamFn | Custom headers or body wrappers on the standard stream path |
resolveTransportTurnState | Native per-turn headers, metadata, WebSocket headers, and cool-down |
resolveWebSocketSessionPolicy | Deprecated WebSocket compatibility hook, replaced by resolveTransportTurnState |
formatApiKey | Custom runtime token shape |
loginOAuth | Callback-based OAuth login for the session SDK AuthStorage API |
refreshOAuth | Custom OAuth refresh handling |
buildAuthDoctorHint | Guidance for auth repair |
matchesContextOverflowError | Overflow detection owned by the provider |
classifyFailoverReason | Rate-limit or overload classification owned by the provider |
isCacheTtlEligible | Gating prompt cache TTL |
buildMissingAuthMessage | Custom hint for missing auth |
augmentModelCatalog | Synthetic forward-compat rows, deprecated in favor of registerModelCatalogProvider |
resolveThinkingProfile | Model-specific /think option set |
isBinaryThinking | Binary thinking on/off compatibility, deprecated in favor of resolveThinkingProfile |
supportsXHighThinking | xhigh reasoning support compatibility, deprecated in favor of resolveThinkingProfile |
resolveDefaultThinkingLevel | Default /think policy compatibility, deprecated in favor of resolveThinkingProfile |
isModernModelRef | Matching models for live or smoke tests |
prepareRuntimeAuth | Token exchange before inference runs |
resolveUsageAuth | Parsing custom usage credentials |
fetchUsageSnapshot | Custom usage endpoint |
createEmbeddingProvider | Provider-owned embedding adapter for memory or search |
buildReplayPolicy | Custom transcript replay or compaction policy |
sanitizeReplayHistory | Provider-specific replay rewrites after generic cleanup |
validateReplayTurns | Strict replay-turn validation before the embedded runner |
onModelSelected | Post-selection callback, such as telemetry |
Runtime fallback notes:
- For a given provider id,
normalizeConfigpicks a single owning plugin, giving bundled providers priority and then falling back to the matched runtime plugin, and invokes only that hook. No sweep across other providers occurs. The normalization ofgoogle/google-vertex/google-antigravityconfiguration entries is handled by Google's ownnormalizeConfighook, not by a separate core fallback. - When exposed,
resolveConfigApiKeyrelies on the provider hook. Amazon Bedrock keeps AWS env-marker resolution inside its provider plugin; runtime auth, when set up withauth: "aws-sdk", still follows the AWS SDK default chain. - The selected
provider,modelId, an optional mergedreasoningcatalog hint, and optional merged modelcompatfacts are whatresolveThinkingProfile(ctx)receives. Limitcompatto picking the provider's thinking UI or profile. - For a model family,
resolveSystemPromptContributionlets a provider inject cache-aware system-prompt guidance. When the behavior is scoped to one provider or model family and the stable/dynamic cache split must be preserved, choose it over the older plugin-widebefore_prompt_buildhook.
Add extra capabilities (optional)
Step 5: Add extra capabilities
Alongside text inference, a provider plugin can register embeddings, speech, realtime transcription, realtime voice, media understanding, image generation, video generation, web fetch, and web search. OpenClaw labels this a hybrid-capability plugin, the recommended approach for company plugins where one plugin covers a single vendor. Refer to Internals: Capability Ownership.
Inside register(api), register each capability next to your existing api.registerProvider(...) call. Only pick the tabs you require:
Speech (TTS)
import {
assertOkOrThrowProviderError,
postJsonRequest,
} from "openclaw/plugin-sdk/provider-http";
api.registerSpeechProvider({
id: "acme-ai",
label: "Acme Speech",
defaultTimeoutMs: 120_000,
isConfigured: ({ config }) => Boolean(config.messages?.tts),
synthesize: async (req) => {
const { response, release } = await postJsonRequest({
url: "https://api.example.com/v1/speech",
headers: new Headers({ "Content-Type": "application/json" }),
body: { text: req.text },
timeoutMs: req.timeoutMs,
fetchFn: fetch,
auditContext: "acme speech",
});
try {
await assertOkOrThrowProviderError(response, "Acme Speech API error");
return {
audioBuffer: Buffer.from(await response.arrayBuffer()),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: false,
};
} finally {
await release();
}
},
});
For provider HTTP failures, use assertOkOrThrowProviderError(...) so plugins share capped error-body reads, JSON error parsing, and request-id suffixes.
Realtime transcription
createRealtimeTranscriptionWebSocketSession(...) is the preferred choice, since the shared helper manages proxy capture, reconnect backoff, close flushing, ready handshakes, audio queueing, and close-event diagnostics. Your plugin only needs to map upstream events.
api.registerRealtimeTranscriptionProvider({
id: "acme-ai",
label: "Acme Realtime Transcription",
isConfigured: () => true,
createSession: (req) => {
const apiKey = String(req.providerConfig.apiKey ?? "");
return createRealtimeTranscriptionWebSocketSession({
providerId: "acme-ai",
callbacks: req,
url: "wss://api.example.com/v1/realtime-transcription",
headers: { Authorization: `Bearer ${apiKey}` },
onMessage: (event, transport) => {
if (event.type === "session.created") {
transport.sendJson({ type: "session.update" });
transport.markReady();
return;
}
if (event.type === "transcript.final") {
req.onTranscript?.(event.text);
}
},
sendAudio: (audio, transport) => {
transport.sendJson({
type: "audio.append",
audio: audio.toString("base64"),
});
},
onClose: (transport) => {
transport.sendJson({ type: "audio.end" });
},
});
},
});
Batch STT providers that POST multipart audio should pull buildAudioTranscriptionFormData(...) from openclaw/plugin-sdk/provider-http. Upload filenames get normalized by the helper, including AAC uploads that require an M4A-style filename for compatible transcription APIs.
Realtime voice
api.registerRealtimeVoiceProvider({
id: "acme-ai",
label: "Acme Realtime Voice",
capabilities: {
transports: ["gateway-relay"],
inputAudioFormats: [{ encoding: "pcm16", sampleRateHz: 24000, channels: 1 }],
outputAudioFormats: [{ encoding: "pcm16", sampleRateHz: 24000, channels: 1 }],
supportsBargeIn: true,
handlesInputAudioBargeIn: true,
supportsToolCalls: true,
},
isConfigured: ({ providerConfig }) => Boolean(providerConfig.apiKey),
createBridge: (req) => ({
// Set this only if the provider accepts multiple tool responses for
// one call, for example an immediate "working" response followed by
// the final result.
supportsToolResultContinuation: false,
connect: async () => {},
sendAudio: () => {},
setMediaTimestamp: () => {},
handleBargeIn: () => {},
submitToolResult: () => {},
acknowledgeMark: () => {},
close: () => {},
isConnected: () => true,
}),
});
Declare capabilities so talk.catalog can surface valid modes, transports, audio formats, and feature flags to browser and native Talk clients. When a transport can detect a human interrupting assistant playback and the provider supports truncating or clearing the active audio response, implement handleBargeIn. For synchronous submission, submitToolResult may return void, or a Promise<void> for an asynchronous completion boundary that the provider bridge can expose. Gateway relay sessions wait on that promise before confirming a final result or clearing the linked run; reject it when submission fails. Set supportsToolResultSuppression: false when the provider cannot honor options.suppressResponse. OpenClaw then skips suppression for internal forced-consult and cancellation results, and rejects direct suppressed-result requests instead of silently launching a response. Consumers of createRealtimeVoiceBridgeSession may likewise return a promise from onToolCall; synchronous throws and rejections go to the session's onError callback. While the response state is idle, the host may pass sendUserMessage(text, { toolChoice }) to force one named function for that response; later responses revert to the session's configured tool choice. Only set handlesInputAudioBargeIn when provider VAD confirms an interruption by calling onClearAudio("barge-in"). Providers that omit the flag rely on OpenClaw's local input-audio fallback detection.
A browser-session request can include gatewayControl when the host has explicitly negotiated server-owned provider control. Vendor authentication and signaling stay private with the provider, which calls gatewayControl.bindBridge(bridge) before connecting the attached control transport and forwards bridge events through the supplied callbacks. Tool policy and run lifecycle remain with the Gateway. Never infer or enable this mode from a model name alone.
Media understanding
api.registerMediaUnderstandingProvider({
id: "acme-ai",
capabilities: ["image", "audio"],
describeImage: async (req) => ({ text: "A photo of..." }),
transcribeAudio: async (req) => ({ text: "Transcript..." }),
});
Local or self-hosted media providers that intentionally skip credentials can expose resolveAuth and return kind: "none". For providers that do not explicitly opt in, OpenClaw still enforces the normal auth gate. Existing providers can continue reading req.apiKey; new providers should favor req.auth.
api.registerMediaUnderstandingProvider({
id: "local-audio",
capabilities: ["audio"],
resolveAuth: () => ({
kind: "none",
source: "local-audio plugin no-auth",
}),
transcribeAudio: async (req) => ({ text: "Transcript..." }),
});
Embeddings
api.registerEmbeddingProvider({
id: "acme-ai",
defaultModel: "acme-embed",
transport: "remote",
authProviderId: "acme-ai",
create: async ({ model }) => ({
provider: {
id: "acme-ai",
model,
dimensions: 1536,
embed: async (input) => {
const text = typeof input === "string" ? input : input.text;
return fetchAcmeEmbedding(text);
},
embedBatch: async (inputs) =>
Promise.all(
inputs.map((input) =>
fetchAcmeEmbedding(typeof input === "string" ? input : input.text),
),
),
},
}),
});
Declare the same id in contracts.embeddingProviders. This serves as the general embedding contract for reusable vector generation, including memory search. For existing memory-specific adapters, registerMemoryEmbeddingProvider(...) is the deprecated compatibility path.
Image and video generation
Image and video functionality relies on a mode-aware structure. Image providers must supply the required generate and edit capability blocks, while video providers need generate, imageToVideo, and videoToVideo. Simple aggregate fields such as maxInputImages / maxInputVideos / maxDurationSeconds cannot adequately signal transform-mode support or disabled modes. Music generation adopts the same generate / edit approach.
api.registerImageGenerationProvider({
id: "acme-ai",
label: "Acme Images",
capabilities: {
generate: { maxCount: 4, supportsSize: true },
edit: { enabled: false },
},
generateImage: async (req) => ({
images: [
{
buffer: await generateAcmeImageBytes(req),
mimeType: "image/png",
fileName: "acme-image.png",
},
],
}),
});
api.registerVideoGenerationProvider({
id: "acme-ai",
label: "Acme Video",
defaultTimeoutMs: 600_000,
models: ["acme-video", "acme-image-video"],
capabilities: {
generate: { maxVideos: 1, maxDurationSeconds: 10, supportsResolution: true },
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
maxInputImagesByModel: { "acme/reference-to-video": 9 },
maxDurationSeconds: 5,
},
videoToVideo: { enabled: false },
},
catalogByModel: {
"acme-image-video": {
modes: ["imageToVideo"],
capabilities: {
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
resolutions: ["480P", "720P", "1080P"],
supportsResolution: true,
},
videoToVideo: { enabled: false },
},
},
},
generateVideo: async (req) => ({
videos: [
{
url: await generateAcmeVideoUrl(req),
mimeType: "video/mp4",
},
],
}),
});
The illustrative helpers stand in for actual provider calls: the image helper produces non-empty encoded bytes, whereas the video helper yields a hosted media URL. Video providers can alternatively return non-empty encoded bytes, or both when the URL acts as a delivery fallback. Empty result arrays and empty buffers count as potential failures, except that a video asset with a usable URL disregards an empty buffer and proceeds with the URL.
capabilities is mandatory for both provider types; edit and the video transform blocks (imageToVideo, videoToVideo) always demand an explicit enabled flag.
When a listed model's static modes or capabilities deviate from provider defaults, use catalogByModel. This metadata keeps video_generate action=list and model catalogs accurate without executing provider code. Request-time capability lookup and enforcement still reside in resolveModelCapabilities and generateVideo; whenever possible, share the same capability constant across both paths.
Web fetch and search
api.registerWebFetchProvider({
id: "acme-ai-fetch",
label: "Acme Fetch",
hint: "Fetch pages through Acme's rendering backend.",
envVars: ["ACME_FETCH_API_KEY"],
placeholder: "acme-...",
signupUrl: "https://acme.example.com/fetch",
credentialPath: "plugins.entries.acme.config.webFetch.apiKey",
getCredentialValue: (fetchConfig) => fetchConfig?.acme?.apiKey,
setCredentialValue: (fetchConfigTarget, value) => {
const acme = (fetchConfigTarget.acme ??= {});
acme.apiKey = value;
},
createTool: () => ({
description: "Fetch a page through Acme Fetch.",
parameters: {},
execute: async (args) => ({ content: [] }),
}),
});
api.registerWebSearchProvider({
id: "acme-ai-search",
label: "Acme Search",
hint: "Search the web through Acme's search backend.",
envVars: ["ACME_SEARCH_API_KEY"],
placeholder: "acme-...",
signupUrl: "https://acme.example.com/search",
credentialPath: "plugins.entries.acme.config.webSearch.apiKey",
getCredentialValue: (searchConfig) => searchConfig?.acme?.apiKey,
setCredentialValue: (searchConfigTarget, value) => {
const acme = (searchConfigTarget.acme ??= {});
acme.apiKey = value;
},
createTool: () => ({
description: "Search the web through Acme Search.",
parameters: {},
execute: async (args) => ({ content: [] }),
}),
});
Both provider types adopt the identical credential-wiring layout: hint, envVars, placeholder, signupUrl, credentialPath, getCredentialValue, setCredentialValue, and createTool are all required.
Test
Step 6: Test
import { describe, it, expect } from "vitest";
// Export your provider config object from index.ts or a dedicated file
import { acmeProvider } from "./provider.js";
describe("acme-ai provider", () => {
it("resolves dynamic models", () => {
const model = acmeProvider.resolveDynamicModel!({
modelId: "acme-beta-v3",
} as any);
expect(model.id).toBe("acme-beta-v3");
expect(model.provider).toBe("acme-ai");
});
it("returns catalog when key is available", async () => {
const result = await acmeProvider.catalog!.run({
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
} as any);
expect(result?.provider?.models).toHaveLength(2);
});
it("returns null catalog when no key", async () => {
const result = await acmeProvider.catalog!.run({
resolveProviderApiKey: () => ({ apiKey: undefined }),
} as any);
expect(result).toBeNull();
});
});
Publish to ClawHub
Provider plugins are published in the same manner as any other external code plugin:
clawhub package publish your-org/your-plugin --dry-run
clawhub package publish your-org/your-plugin
clawhub skill publish <path> serves a different purpose, publishing a skill folder rather than a plugin package, so avoid it here.
File structure
<bundled-plugin-root>/acme-ai/
├── package.json # openclaw.providers metadata
├── openclaw.plugin.json # Manifest with provider auth metadata
├── index.ts # definePluginEntry + registerProvider
└── src/
├── provider.test.ts # Tests
└── usage.ts # Usage endpoint (optional)
Catalog order reference
catalog.order determines when your catalog merges relative to built-in providers:
| Order | When | Use case |
|---|---|---|
simple | First pass | Plain API-key providers |
profile | After simple | Providers gated on auth profiles |
paired | After profile | Synthesize multiple related entries |
late | Last pass | Override existing providers (wins on collision) |
Next steps
- Channel Plugins - if your plugin also provides a channel
- SDK Runtime -
api.runtimehelpers (TTS, search, subagent) - SDK Overview - full subpath import reference
- Plugin Internals - hook details and bundled examples