Chapter 23

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:

  1. Each model's behavior is controlled by the Plugin that owns that model (intuitive)
  2. 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:

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:

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

Non-Provider Plugin

// โœ“ 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:

  1. Phases are the starting point for understandingโ€”understand what problem each phase solves before diving into specific Hooks
  2. The ownership principleโ€”the Plugin that owns the model's catalog gets priority in the passthrough chain
  3. createStreamFn vs wrapStreamFnโ€”the former creates streams (Provider Plugin); the latter decorates streams (non-Provider Plugin)
  4. Two auth modes for registerHttpRouteโ€”plugin for self-managed auth; gateway for system-managed auth
  5. 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.

Rate this chapter
4.8  / 5  (7 ratings)

๐Ÿ’ฌ Comments