Skip to content

Building Provider Plugins

This guide walks through building a provider plugin that adds a model provider (LLM) to OpenClaw. By the end you will have a provider with a model catalog, API key auth, and dynamic model resolution.

  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"],
    "providerAuthEnvVars": {
    "acme-ai": ["ACME_AI_API_KEY"]
    },
    "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

    ”, “cliDescription”: “Acme AI API key” } ], “configSchema”: { “type”: “object”, “additionalProperties”: false } } ```

    The manifest declares providerAuthEnvVars so OpenClaw can detect credentials without loading your plugin runtime. If you publish the provider on ClawHub, those openclaw.compat and openclaw.build fields are required in package.json.

  2. Register the provider

    A minimal provider needs an id, label, auth, and catalog:

    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,
    },
    ],
    },
    };
    },
    },
    });
    },
    });

    That is a working provider. Users can now `openclaw onboard —acme-ai-api-key

    and select acme-ai/acme-large` as their model.

    For bundled providers that only register one text provider with API-key
    auth plus a single catalog-backed runtime, prefer the narrower
    `defineSingleProviderPluginEntry(...)` helper:
    ```typescript
    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" }],
    }),
    },
    },
    });
    ```
    If your auth flow also needs to patch `models.providers.*`, aliases, and
    the agent default model during onboarding, use the preset helpers from
    `openclaw/plugin-sdk/provider-onboard`. The narrowest helpers are
    `createDefaultModelPresetAppliers(...)`,
    `createDefaultModelsPresetAppliers(...)`, and
    `createModelCatalogPresetAppliers(...)`.
  3. 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,
    }),
    });

    If resolving requires a network call, use prepareDynamicModel for async warm-up — resolveDynamicModel runs again after it completes.

  4. Add runtime hooks (as needed)

    Most providers only need catalog + resolveDynamicModel. Add hooks incrementally as your provider requires them.

    For providers that need a token exchange before each inference call:

    prepareRuntimeAuth: async (ctx) => {
    const exchanged = await exchangeToken(ctx.apiKey);
    return {
    apiKey: exchanged.token,
    baseUrl: exchanged.baseUrl,
    expiresAt: exchanged.expiresAt,
    };
    },
    All available provider hooks

    OpenClaw calls hooks in this order. Most providers only use 2-3:

    #HookWhen to use
    1catalogModel catalog or base URL defaults
    2resolveDynamicModelAccept arbitrary upstream model IDs
    3prepareDynamicModelAsync metadata fetch before resolving
    4normalizeResolvedModelTransport rewrites before the runner
    5capabilitiesTranscript/tooling metadata (data, not callable)
    6prepareExtraParamsDefault request params
    7wrapStreamFnCustom headers/body wrappers
    8formatApiKeyCustom runtime token shape
    9refreshOAuthCustom OAuth refresh
    10buildAuthDoctorHintAuth repair guidance
    11isCacheTtlEligiblePrompt cache TTL gating
    12buildMissingAuthMessageCustom missing-auth hint
    13suppressBuiltInModelHide stale upstream rows
    14augmentModelCatalogSynthetic forward-compat rows
    15isBinaryThinkingBinary thinking on/off
    16supportsXHighThinkingxhigh reasoning support
    17resolveDefaultThinkingLevelDefault /think policy
    18isModernModelRefLive/smoke model matching
    19prepareRuntimeAuthToken exchange before inference
    20resolveUsageAuthCustom usage credential parsing
    21fetchUsageSnapshotCustom usage endpoint
    22onModelSelectedPost-selection callback (e.g. telemetry)
    23buildReplayPolicyCustom transcript policy (e.g. thinking-block stripping)
    24sanitizeReplayHistoryProvider-specific replay rewrites after generic cleanup
    25validateReplayTurnsStrict replay-turn validation before the embedded runner

    For detailed descriptions and real-world examples, see Internals: Provider Runtime Hooks.

  5. Add extra capabilities (optional)

    A provider plugin can register speech, media understanding, image generation, and web search alongside text inference:

    register(api) {
    api.registerProvider({ id: "acme-ai", /* ... */ });
    api.registerSpeechProvider({
    id: "acme-ai",
    label: "Acme Speech",
    isConfigured: ({ config }) => Boolean(config.messages?.tts),
    synthesize: async (req) => ({
    audioBuffer: Buffer.from(/* PCM data */),
    outputFormat: "mp3",
    fileExtension: ".mp3",
    voiceCompatible: false,
    }),
    });
    api.registerMediaUnderstandingProvider({
    id: "acme-ai",
    capabilities: ["image", "audio"],
    describeImage: async (req) => ({ text: "A photo of..." }),
    transcribeAudio: async (req) => ({ text: "Transcript..." }),
    });
    api.registerImageGenerationProvider({
    id: "acme-ai",
    label: "Acme Images",
    generate: async (req) => ({ /* image result */ }),
    });
    }

    OpenClaw classifies this as a hybrid-capability plugin. This is the recommended pattern for company plugins (one plugin per vendor). See Internals: Capability Ownership.

  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();
    });
    });

Provider plugins publish the same way as any other external code plugin:

Terminal window
clawhub package publish your-org/your-plugin --dry-run
clawhub package publish your-org/your-plugin

Do not use the legacy skill-only publish alias here; plugin packages should use clawhub package publish.

/acme-ai/ ├── package.json # openclaw.providers metadata ├── openclaw.plugin.json # Manifest with providerAuthEnvVars ├── index.ts # definePluginEntry + registerProvider └── src/ ├── provider.test.ts # Tests └── usage.ts # Usage endpoint (optional)

## Catalog order reference
`catalog.order` controls 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](/en/plugins/sdk-channel-plugins) — if your plugin also provides a channel
- [SDK Runtime](/en/plugins/sdk-runtime) — `api.runtime` helpers (TTS, search, subagent)
- [SDK Overview](/en/plugins/sdk-overview) — full subpath import reference
- [Plugin Internals](/en/plugins/architecture#provider-runtime-hooks) — hook details and bundled examples