Plugin API Deep Dive: 45 Hooks Across 7 Phases and Penetration Rules
Chapter 23ใPlugin API Complete Guide: The 7-Phase Call Order and Passthrough Rules for All 45 Hooks
23.1ใThe Overall Design Logic of the 7 Phases
OpenClaw's 45 Hooks are not a random collection of APIsโthey are organized within a strictly defined processing pipeline. Every time the system needs to handle an LLM request, this pipeline executes from start to finish.
Understanding the design logic of the 7 phases matters more than memorizing the signature of every Hook. Here is the overall design thinking:
User request
โ
[Phase 1] Config Materialization โ What is the Provider? What are its defaults?
โ
[Phase 2] Model Resolution โ Which specific model? Which transport layer?
โ
[Phase 3] Auth Resolution โ Which API Key? Is there external auth?
โ
[Phase 4] Model Preparation โ Does the dynamic model need special setup? What are its capabilities?
โ
[Phase 5] Inference Configuration โ How to normalize the schema? Stream or non-stream?
โ
[Phase 6] Streaming โ How to create/wrap the stream? What is the transport turn state?
โ
[Phase 7] Runtime โ Prepare auth / snapshots / embeddings / replay policy
โ
Actual LLM call
Each phase has a clear responsibility boundaryโthe output of the previous phase is the input to the next. Plugins participate in the processing pipeline by registering Hooks at specific phases.
23.2ใPhase 1: Config Materialization
Core question: What is the base configuration for this Provider?
Hook: catalog
When called: At system startup, and whenever the Provider's capability catalog needs to be fetched
Role: Provider Plugins declare all supported models to the system through the catalog hook. This is the most fundamental hook for a Provider Plugin.
api.registerProvider({
id: 'my-provider',
label: 'My Provider',
// catalog hook: returns descriptions of all available models
catalog: async () => ({
models: [
{
id: 'my-provider/fast-v1',
displayName: 'Fast Model v1',
contextWindow: 128000,
capabilities: {
streaming: true,
functionCalling: true,
vision: false,
},
pricing: {
inputPerMToken: 0.50,
outputPerMToken: 1.50,
}
},
{
id: 'my-provider/powerful-v2',
displayName: 'Powerful Model v2',
contextWindow: 200000,
capabilities: {
streaming: true,
functionCalling: true,
vision: true,
}
}
]
})
})
Hook: applyConfigDefaults
When called: After the Config Normalization step completes, before model resolution
Role: Fills in dynamic default values for Provider configuration. Unlike static defaults in the Manifest schema, applyConfigDefaults can fill defaults based on the runtime environment (e.g., choosing a default model based on the current OS).
api.registerProvider({
id: 'my-provider',
applyConfigDefaults: (config, context) => {
// If the user hasn't specified a default model, choose based on environment
if (!config.defaultModel) {
config.defaultModel = context.isLowMemoryEnvironment
? 'my-provider/fast-v1'
: 'my-provider/powerful-v2'
}
return config
}
})
23.3ใPhase 2: Model Resolution
Core question: Which actual model does the user-specified model string correspond to? What transport layer is used?
Hook: normalizeModelId
When called: When a request containing a model ID is received; called first among all resolution hooks
Role: Maps aliases, abbreviations, and legacy IDs that users might use to the current canonical ID.
Passthrough rule: normalizeModelId first checks the Provider Plugin that owns the model (ownership principle), then passes through to other hook-capable Plugins.
api.registerProvider({
id: 'my-provider',
normalizeModelId: (modelId, context) => {
// Handle various aliases the user might use
const aliases: Record<string, string> = {
'fast': 'my-provider/fast-v1',
'my-fast': 'my-provider/fast-v1',
'v1': 'my-provider/fast-v1',
'powerful': 'my-provider/powerful-v2',
}
if (aliases[modelId]) {
return aliases[modelId]
}
// Return null to indicate "I don't own this model, pass through to next Plugin"
return null
}
})
Hook: normalizeTransport
When called: After normalizeModelId
Role: Decides which transport protocol variant to use (OpenAI-compatible REST / gRPC / WebSocket / custom HTTP).
api.registerProvider({
id: 'my-provider',
normalizeTransport: (modelId, config) => {
if (modelId === 'my-provider/powerful-v2') {
// Large model uses streaming gRPC
return { type: 'grpc', streaming: true }
}
// Default to OpenAI-compatible REST
return { type: 'openai-compat', baseUrl: config.baseUrl }
}
})
Hook: normalizeConfig
When called: After normalizeTransport
Role: Final normalization of request configuration (merging Profile config, handling special field mappings, etc.).
api.registerProvider({
id: 'my-provider',
normalizeConfig: (rawConfig, modelId) => {
return {
...rawConfig,
// Map OpenClaw standard fields to Provider API fields
max_tokens: rawConfig.maxOutputTokens ?? 4096,
temperature: rawConfig.temperature ?? 0.7,
}
}
})
23.4ใPhase 3: Auth Resolution
Core question: Which credentials are used to make the request?
Hook: resolveConfigApiKey
When called: After model resolution completes; the first auth-related hook
Role: Extracts the API Key from the already-decrypted configuration. The most commonly used auth hook.
api.registerProvider({
id: 'my-provider',
resolveConfigApiKey: (config) => {
// config.apiKey has already been decrypted by Config Normalization
return config.apiKey ?? null
}
})
Hook: resolveSyntheticAuth
When called: After resolveConfigApiKey; for scenarios requiring non-API-Key auth
Role: Generates synthetic authentication tokens (e.g., JWT, OAuth Token, HMAC signature).
api.registerProvider({
id: 'my-provider',
resolveSyntheticAuth: async (config, modelId) => {
if (config.authType === 'oauth') {
const token = await oauthClient.getAccessToken(config.clientId, config.clientSecret)
return { type: 'bearer', token }
}
return null // Don't handle; pass through
}
})
Hook: resolveExternalAuthProfiles
When called: After resolveSyntheticAuth
Role: Resolves credential Profiles from external auth systems (AWS IAM / GCP Workload Identity / Azure Managed Identity).
Hook: shouldDeferSyntheticProfileAuth
When called: During the auth resolution process
Role: Decides whether to defer authentication of a synthetic Profile to request time (rather than startup time). Used for short-lived token scenarios.
23.5ใPhase 4: Model Preparation
Core question: Does this model need special preparation? What is its capability set?
Hook: resolveDynamicModel
When called: After auth resolution completes
Role: For "dynamic models" where the actual model must be determined at runtime (e.g., routing to different models based on current load), model selection is finalized in this hook.
api.registerProvider({
id: 'my-provider',
resolveDynamicModel: async (modelId, config, auth) => {
if (modelId === 'my-provider/auto') {
// Dynamically select model based on current server load
const load = await getServerLoad()
return load > 0.8
? 'my-provider/fast-v1'
: 'my-provider/powerful-v2'
}
return modelId // Non-dynamic model: return as-is
}
})
Hook: prepareDynamicModel
When called: After resolveDynamicModel, only when a dynamic model has been selected
Role: Performs initialization for dynamically routed models (e.g., warming connection pools, requesting session tokens).
Hook: normalizeResolvedModel
When called: After dynamic model resolution completes
Role: Final normalization of the definitively-selected model ID, ensuring it fully conforms to system internal expectations.
Hook: contributeResolvedModelCompat
When called: After normalizeResolvedModel
Role: Injects compatibility configuration for the resolved model (e.g., "this model doesn't support JSON mode; a prompt-level workaround is needed").
api.registerProvider({
id: 'my-provider',
contributeResolvedModelCompat: (modelId) => {
if (modelId === 'my-provider/fast-v1') {
return {
supportsJsonMode: false,
supportsSystemMessage: true,
maxToolsPerRequest: 10,
}
}
return null
}
})
Hook: capabilities
When called: At the end of the entire model preparation phase
Role: Returns the definitively-determined model capability set. This is the basis for the system to decide "what can be done with this model."
api.registerProvider({
id: 'my-provider',
capabilities: (modelId, resolvedModel) => ({
streaming: true,
functionCalling: true,
vision: modelId.includes('v2'),
contextWindow: modelId.includes('v2') ? 200000 : 128000,
outputFormats: ['text', 'json'],
})
})
23.6ใPhase 5: Inference Configuration
Core question: How to prepare the request parameters to send to the model?
This phase includes several schema normalization hooks (handling format differences in tool-calling schemas) and inference output mode selection hooks (deciding whether to use structured output). Key hooks are highlighted below.
Schema normalization hooks
Different Providers have subtle differences in tool-calling schema format. This group of hooks lets Provider Plugins normalize schemas before requests are sent:
api.registerProvider({
id: 'my-provider',
// Normalize tool definition schema
normalizeToolSchema: (tool) => {
// my-provider doesn't support additionalProperties: false
const { additionalProperties, ...schemaWithout } = tool.inputSchema
return { ...tool, inputSchema: schemaWithout }
},
// Normalize output schema (for structured output mode)
normalizeOutputSchema: (schema) => {
// my-provider's JSON mode requires a top-level title field
return { title: 'Response', ...schema }
}
})
Inference output mode selection
api.registerProvider({
id: 'my-provider',
// Decide whether to use JSON structured output
selectInferenceOutputMode: (request, modelCapabilities) => {
if (request.outputSchema && modelCapabilities.supportsJsonMode) {
return 'structured-json'
}
return 'text'
}
})
23.7ใPhase 6: Streaming
Core question: How to create and wrap the data stream between OpenClaw and the Provider?
This is the most technically intensive phase in the Plugin API, and the key differentiator of Provider Plugins from other Plugin types.
Hook: createStreamFn
When called: After all configuration preparation is complete, when actual LLM invocation begins
Role: Creates the raw stream function for communicating with the Provider. This is the core hook that Provider Plugins must implement.
Selection principle: If you are building a Provider Plugin (integrating a new LLM), implement createStreamFn.
api.registerProvider({
id: 'my-provider',
createStreamFn: (config, auth) => {
// Returns a function that accepts request parameters and returns AsyncIterable<StreamChunk>
return async function* streamFn(request: LLMRequest): AsyncIterable<StreamChunk> {
const response = await fetch(`${config.baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${auth.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: request.modelId,
messages: request.messages,
stream: true,
temperature: request.temperature,
max_tokens: request.maxOutputTokens,
}),
})
if (!response.ok) {
throw new ProviderError(`HTTP ${response.status}: ${await response.text()}`)
}
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
const lines = text.split('\n')
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6)
if (data === '[DONE]') return
try {
const parsed = JSON.parse(data)
const delta = parsed.choices?.[0]?.delta
if (delta?.content) {
yield { type: 'text', content: delta.content }
}
if (delta?.tool_calls) {
yield { type: 'tool_call', toolCalls: delta.tool_calls }
}
} catch {
// Ignore parse errors in SSE lines
}
}
}
}
}
})
Hook: wrapStreamFn
When called: After createStreamFn; wraps an existing stream function
Role: Decorates the stream without replacing the entire stream function (adds monitoring, retry logic, rate limiting, etc.).
Selection principle: If you are building a non-Provider Plugin (e.g., a monitoring Plugin, a billing Plugin), implement wrapStreamFn rather than createStreamFn.
// Monitoring Plugin: doesn't replace the stream function, only wraps it
api.registerProvider({
id: 'my-monitoring-plugin',
wrapStreamFn: (originalStream) => {
return async function* wrappedStream(request: LLMRequest): AsyncIterable<StreamChunk> {
const startTime = Date.now()
let tokenCount = 0
try {
for await (const chunk of originalStream(request)) {
if (chunk.type === 'text') {
tokenCount += estimateTokens(chunk.content)
}
yield chunk // Pass through original chunks
}
} finally {
// Record metrics regardless of success or failure
await metrics.record({
provider: request.providerId,
model: request.modelId,
durationMs: Date.now() - startTime,
outputTokens: tokenCount,
})
}
}
}
})
Choosing Between createStreamFn and wrapStreamFn
| Scenario | Which to use |
|---|---|
| Integrating a new LLM Provider | createStreamFn |
| Adding monitoring/telemetry | wrapStreamFn |
| Adding retry logic | wrapStreamFn |
| Adding rate limiting | wrapStreamFn |
| Implementing a Model Router | createStreamFn (routes to other providers) |
| Implementing response caching | wrapStreamFn |
Hook: resolveTransportTurnState
When called: After the stream is established
Role: Resolves "Turn State" bound to the specific transport layerโtransport-layer information that needs to persist across conversation turns (e.g., WebSocket session IDs, gRPC stream handles).
23.8ใPhase 7: Runtime
Core question: What else needs to be prepared before the request is sent?
Hook: prepareRuntimeAuth
When called: Before the streaming phase; final auth confirmation
Role: Handles authentication that needs refreshing on each request (e.g., expiring temporary credentials).
api.registerProvider({
id: 'my-provider',
prepareRuntimeAuth: async (existingAuth, request) => {
// Check if JWT expires soon (within 5 minutes)
if (isTokenExpiringSoon(existingAuth.token, 300)) {
const newToken = await refreshJwt(existingAuth.refreshToken)
return { ...existingAuth, token: newToken }
}
return existingAuth
}
})
Hook: fetchUsageSnapshot
When called: Before a request (for quota checking) and after a request (for billing records)
Role: Fetches the current usage snapshot for the Provider, used for quota checking and usage statistics.
Hook: createEmbeddingProvider
When called: When the system needs text embedding capability
Role: Creates an embedding function for embedding models provided by the Provider.
api.registerProvider({
id: 'my-provider',
createEmbeddingProvider: (config, auth) => ({
embed: async (texts: string[]): Promise<number[][]> => {
const response = await fetch(`${config.baseUrl}/v1/embeddings`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${auth.apiKey}` },
body: JSON.stringify({
model: 'my-provider/embed-v1',
input: texts,
}),
})
const data = await response.json()
return data.data.map((d: any) => d.embedding)
}
})
})
Hook: buildReplayPolicy
When called: During error handling
Role: Defines the replay (retry) policy when a request fails.
api.registerProvider({
id: 'my-provider',
buildReplayPolicy: (error, request) => {
// 429 Too Many Requests: exponential backoff retry
if (error.status === 429) {
return {
shouldRetry: true,
delayMs: Math.min(1000 * Math.pow(2, request.attemptNumber), 30000),
maxAttempts: 5,
}
}
// 500 Server Error: retry once immediately
if (error.status >= 500) {
return { shouldRetry: true, delayMs: 100, maxAttempts: 2 }
}
// Other errors: don't retry
return { shouldRetry: false }
}
})
23.9ใPassthrough Rules Explained
The Passthrough Rule is the concept most likely to cause confusion in the Plugin system. This section explains it from first principles.
When Passthrough Rules Trigger
When the system executes the three hooks normalizeModelId, normalizeTransport, and normalizeConfig, it needs to decide: which Plugin's implementation to call?
The Ownership Principle
Each model ID has exactly one "owner"โthe Provider Plugin that registered that model in its catalog.
Model ID: "my-provider/fast-v1"
โ
my-provider Plugin owns this model
Passthrough Execution Order
For normalizeModelId (using it as an example):
1. System first calls normalizeModelId of the Provider Plugin that "owns the model"
โ If that Plugin returns a non-null value: use that value and stop
โ If that Plugin returns null: pass through
2. Call other hook-capable Plugins that implement normalizeModelId, in order
โ Until some Plugin returns non-null, or all Plugins return null
3. If all Plugins return null: use the original model ID (no transformation)
Engineering Significance of Passthrough Rules
Passthrough rules solve the control attribution problem in multi-Plugin environments. Without passthrough rules, if multiple Plugins all implement normalizeModelId, the system wouldn't know which one's output to use.
Passthrough rules give a clear answer: owner has priority; other Plugins serve as supplements. This ensures:
- Each model's behavior is controlled by the Plugin that owns that model (intuitive)
- Non-Provider Plugins (e.g., routing Plugins) can take over under specific conditions (by returning non-null)
23.10ใregisterHttpRoute Security Tiers
api.registerHttpRoute supports two authentication modes, suited to different use cases.
auth: "plugin"
The Plugin manages authentication entirely on its own; the system does not intervene in validation:
api.registerHttpRoute({
path: '/my-plugin/webhook',
method: 'POST',
auth: 'plugin', // Plugin verifies requests itself
match: 'exact',
handler: async (req, res) => {
// Must verify here yourself
const signature = req.headers['x-webhook-signature']
if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' })
}
// Process webhook
await processWebhookEvent(req.body)
res.json({ ok: true })
}
})
Appropriate for:
- Receiving third-party webhooks (Slack, GitHub, Stripe, etc.)
- Internal APIs that don't require OpenClaw operator scope validation
- Plugins with their own authentication scheme (HMAC signatures, custom tokens, etc.)
auth: "gateway"
The system verifies that the requester holds a valid operator.write scope before calling the handler:
api.registerHttpRoute({
path: '/my-plugin/admin/reset',
method: 'POST',
auth: 'gateway', // Requires operator.write scope
match: 'exact',
handler: async (req, res) => {
// Reaching here means gateway has verified operator.write scope
// req.operator contains the verified operator information
await performAdminReset(req.body)
res.json({ ok: true })
}
})
Appropriate for:
- Admin APIs that require operator-level permissions
- Endpoints that need to integrate with OpenClaw's main authentication system
- Sensitive operations (resetting configuration, clearing caches, etc.)
match: "exact" vs "prefix"
// exact: only matches /my-plugin/webhook (not /my-plugin/webhook/sub)
api.registerHttpRoute({ path: '/my-plugin/webhook', match: 'exact', ... })
// prefix: matches all paths starting with /my-plugin/
api.registerHttpRoute({ path: '/my-plugin/', match: 'prefix', ... })
23.11ใapi.runtime Helpers: Usage Scenarios
api.runtime provides controlled access to OpenClaw runtime capabilities, so Plugins don't need to implement these capabilities themselves.
api.runtime.tts.*
Text-to-speech capability, allowing Plugins to use TTS Providers registered with OpenClaw:
api.registerChannel({
id: 'my-voice-channel',
onMessage: async (message, ctx) => {
if (message.responseFormat === 'audio') {
const audio = await ctx.runtime.tts.synthesize({
text: message.content,
voice: 'default',
format: 'mp3',
})
return { type: 'audio', data: audio }
}
return { type: 'text', content: message.content }
}
})
api.runtime.subagent.*
Launching sub-Agent tasks from within a Plugin:
api.registerTool({
id: 'my-tool.analyze',
schema: { /* ... */ },
handler: async (params, ctx) => {
// Launch a sub-Agent to complete complex analysis
const result = await ctx.runtime.subagent.run({
task: `Analyze this data and return insights: ${JSON.stringify(params.data)}`,
model: 'my-provider/powerful-v2',
maxIterations: 10,
})
return result.output
}
})
api.runtime.webSearch.*
Using registered WebSearch Providers:
const results = await ctx.runtime.webSearch.search({
query: 'OpenClaw Plugin API documentation',
maxResults: 10,
})
api.runtime.imageGeneration.*
Using registered image generation Providers:
const image = await ctx.runtime.imageGeneration.generate({
prompt: 'A detailed diagram of Plugin loading pipeline',
size: '1024x1024',
format: 'png',
})
23.12ใProvider Plugin vs. Non-Provider Plugin: Key Differences
Understanding when to use registerProvider versus other APIs is essential for choosing the right Plugin type.
Provider Plugin
- Uses
api.registerProvider({ id, label, auth, catalog, ...hooks }) - Must implement the
cataloghook (declare supported models) - Typically implements
createStreamFn(provides actual LLM invocation capability) - Gains "control" over specific models through passthrough rules
- Participates in all Hook phases across all 7 stages
Non-Provider Plugin
- Uses
registerChannel,registerTool,registerCommand,registerHttpRoute, etc. - Does not implement
catalogorcreateStreamFn - Can implement
wrapStreamFn(participate in stream processing without owning a model) - Can implement some hooks (like
buildReplayPolicy) to influence the behavior of all models
// โ Legitimate use of a non-Provider Plugin:
// Implement wrapStreamFn to add telemetry to all requests
export function setup(api: PluginApi) {
api.registerProvider({
id: 'telemetry-plugin',
// Note: no catalog, no createStreamFn
// This "provider" registration is solely to implement hooks
wrapStreamFn: (originalStream) => {
return async function* (request) {
// Add telemetry logic...
yield* originalStream(request)
}
},
buildReplayPolicy: (error, request) => {
// Custom retry policy...
}
})
// Also register tools
api.registerTool({
id: 'telemetry.dashboard',
// ...
})
}
23.13ใChapter Summary
45 Hooks may seem complex, but they have an inherent logical structure:
- Phases are the starting point for understandingโunderstand what problem each phase solves before diving into specific Hooks
- The ownership principleโthe Plugin that owns the model's catalog gets priority in the passthrough chain
- createStreamFn vs wrapStreamFnโthe former creates streams (Provider Plugin); the latter decorates streams (non-Provider Plugin)
- Two auth modes for registerHttpRouteโ
pluginfor self-managed auth;gatewayfor system-managed auth - api.runtime helpersโreuse OpenClaw's built-in capabilities instead of reimplementing them
The next chapter translates this API knowledge into practice: writing a Channel Plugin, a Tool Plugin, and a Provider Plugin from scratch.