Media understanding providers
Plugins can register media understanding providers that handle audio transcription, image description, and video understanding. Each provider implements the wire-protocol logic for one or more capabilities and is discoverable by auto-detection. Users enable plugins per entry in config; providers themselves are selected throughtools.media model entries.
Contract
functions wrapper - transcribeAudio, describeImage, and describeVideo sit at the top level of the object passed to api.registerMediaUnderstandingProvider(...). Implement one handler per entry in capabilities.
All handler functions are async; they run inside the plugin host and do not block the gateway, but a slow handler delays the user-visible media result, so honour request.timeoutMs.
Minimal provider
Shared wire helpers
The SDK exports wire-protocol helpers for common patterns. Types come from the SDK root; runtime helpers live on theprovider-media-understanding subpath:
Multipart uploads
PreferpostTranscriptionRequest (or transcribeOpenAiCompatibleAudio, which wraps it) over a hand-rolled fetch for any multipart/form-data upload. All outbound requests pass through the SSRF egress guard, which dispatches on a bundled HTTP client that only recognises its own FormData class. A body built from globalThis.FormData is not recognised, and the request goes out with no boundary and no fields - the endpoint answers with a confusing complaint about a missing parameter rather than a transport error. The shared helpers rebuild the body for the dispatching client, so they are always safe.
If you must build a multipart body yourself, do not set content-type or content-length manually - let the client derive the boundary.
OpenAI-compatible providers
For audio transcription via OpenAI-compatible APIs (Groq, OpenRouter, DeepInfra, and so on):describeOpenAiCompatibleImage / describeOpenAiCompatibleVideo the same way.
Credential resolution
Your handler receives the resolved credential inrequest.apiKey. The runner fills it from the first source that yields a value:
- A stored auth profile for the provider (
auth-profiles.json) - an explicitprofileon the model entry, else the configured profile order. - An environment variable core maps for well-known provider ids.
- Any variable named in your registration’s
envVars, in the order you list them. models.providers.<id>.apiKeyinopenclaw.json.
GEMINI_API_KEY for google, for example, but an operator who exported only GOOGLE_API_KEY depends on it appearing in envVars. The first variable that resolves to a non-empty value wins.
What not to do:
envVars holds credentials only. A non-secret entry is handed to your handler as request.apiKey whenever the real key is unset - base URLs and other non-secret settings belong in the plugin’s own configSchema, not here.
Releases up to and including 0.4.10 treated
envVars as documentation only, so a provider absent from core’s internal map could never authenticate no matter what the operator exported. If you maintain a provider plugin, no change is needed beyond declaring envVars.Borrowing a credential with apiKeyProvider
A media model entry can set apiKeyProvider to split credential resolution from implementation selection: provider keeps naming the wire implementation (your handler), while apiKeyProvider names a models.providers entry that supplies the key, base URL, and headers:
- Credentials resolve against the
apiKeyProviderid (auth profiles, env, and itsmodels.providers.bifrost.apiKey). - Your registration’s
envVarsare not consulted whenapiKeyProvideris set - the credential provider’s contract is unknown to your declaration. - The
models.providers.<apiKeyProvider>entry also suppliesbaseUrlandheadersas fallbacks. Precedence: the model entry’s ownbaseUrlwins, then the capability-level config, then the credential provider’smodels.providersentry. Headers merge with entry-level and capability-level headers taking precedence over the provider entry’s. - Your handler sees the outcome as plain
request.apiKey,request.baseUrl, andrequest.headers- nothing about the borrowing leaks into the wire protocol.
models.providers.bifrost and point media entries at it.
Auto-detection ordering
autoDetectOrder controls which provider is tried first when multiple providers are enabled and no explicit model entry is configured:
autoDetectOrder.audio is lower. Providers whose credentials resolve nowhere are skipped, not attempted.
Request and result shapes
Audio (transcribeAudio): buffer (raw bytes), fileName, apiKey, timeoutMs; optional mime, model, language (BCP 47), prompt, baseUrl, headers, query, fetchFn (tests). Returns { text: string; model?: string }.
Image (describeImage): buffer, fileName, model, provider, timeoutMs, agentDir, cfg; optional mime, prompt, maxTokens, profile / preferredProfile, apiKey, baseUrl, headers, fetchFn. Returns { text: string; model?: string }.
Video (describeVideo): buffer, fileName, apiKey, timeoutMs; optional mime, model, prompt, baseUrl, headers, fetchFn. Returns { text: string; model?: string }.
Blocking the agent loop
Theregister function runs at gateway startup - do not perform synchronous blocking I/O (fs.readFileSync, execSync) inside it. Handlers are async and do not block the gateway, but a slow handler delays the user-visible transcription or description, so set and honour timeouts and return errors rather than hanging.
Do not do this:
Testing your provider
The SDK exports fetch-capturing test doubles:createRequestCaptureJsonFetch and installPinnedHostnameTestHooks are exported from the same subpath.
What’s next
- Web search providers - the same provider pattern for search
- Realtime voice providers - streaming voice instead of file media
- Your first plugin