Skip to main content

Overview

This guide walks you through creating a native Go plugin for Bifrost using our hello-world example as a reference. You’ll learn how to structure your plugin, implement required functions, build the shared object, and integrate it with Bifrost.

Prerequisites

Before you start, ensure you have:
  • Go 1.26.1 installed (must match Bifrost’s Go version)
  • Linux or macOS (Go plugins are not supported on Windows)
  • Bifrost installed and configured
  • Basic understanding of Go programming
Make sure your go.mod has the go version pinned to 1.26.1

Project Structure

A minimal plugin project should have the following structure:

Step 1: Initialize Your Plugin Project

Create a new directory and initialize a Go module:
Add Bifrost as a dependency:
Your go.mod should look like this:

Step 2: Implement the Plugin Interface

Create main.go with the required plugin functions. Here’s the complete hello-world example:
This skeleton only shows the LLM and HTTP transport hooks. Plugins that need to intercept the MCP gateway (per-tool-call governance, transport-setup mutation) can also export PreMCPHook / PostMCPHook / PreMCPConnectionHook / PostMCPConnectionHook — see MCP plugin hooks overview below.

Understanding Each Function

Init(config any) error

Called once when the plugin is loaded. Use this to:
  • Parse plugin configuration
  • Initialize database connections
  • Set up API clients
  • Validate required environment variables

GetName() string

Returns a unique identifier for your plugin. This name appears in logs and status reports.

ctx.Log(level, msg) v1.5.x+

Emits a structured log entry scoped to the current plugin. Use this instead of fmt.Println for production logging - entries are collected per-request and can be retrieved programmatically.
Available log levels: Key points:
  • Thread-safe - safe to call from concurrent goroutines
  • Scoped - each entry is automatically tagged with your plugin’s name
  • Timestamped - entries include Unix millisecond timestamps
  • No-op when unscoped - safe to call in any context; silently ignored outside plugin hooks
  • Logs are retrievable via ctx.GetPluginLogs() (read copy) or ctx.DrainPluginLogs() (transfer ownership)

HTTPTransportPreHook(ctx, req)

HTTP transport only. Intercepts requests BEFORE they enter Bifrost core. Use this to:
  • Modify request headers, body, or query params in-place
  • Short-circuit with a custom response
  • Store values in BifrostContext for use in other hooks
  • Works with both native .so and WASM plugins
Key points:
  • Receives serializable *HTTPRequest (not raw fasthttp)
  • Modify req.Headers, req.Body, req.Query directly
  • Return (nil, nil) to continue to next plugin/handler
  • Return (*HTTPResponse, nil) to short-circuit with response
  • Return (nil, error) to short-circuit with error

HTTPTransportPostHook(ctx, req, resp)

HTTP transport only. Intercepts responses AFTER they exit Bifrost core. Use this to:
  • Modify response headers or body in-place
  • Log or monitor response data
  • Access context values set in pre-hook
  • Called in reverse order of pre-hooks
Key points:
  • Receives both *HTTPRequest and *HTTPResponse
  • Modify resp.Headers, resp.Body, resp.StatusCode directly
  • Return nil to continue to next plugin/handler
  • Return error to short-circuit with error and skip remaining post-hooks
  • NOT called for streaming responses - use HTTPTransportStreamChunkHook instead

HTTPTransportStreamChunkHook(ctx, req, chunk)

HTTP transport only. Intercepts streaming response chunks BEFORE they’re written to the client. Use this to:
  • Modify streaming chunks in real-time
  • Filter/skip specific chunks
  • Log or monitor streaming data
  • Called in reverse order of pre-hooks
Key points:
  • Receives a *schemas.BifrostStreamChunk typed struct (not raw bytes)
  • The struct contains one non-nil field based on the response type (chat, text completion, responses, speech, transcription, image generation, or error)
  • Return (chunk, nil) to pass through unchanged
  • Return (nil, nil) to skip/filter the chunk entirely
  • Return (modifiedChunk, nil) to return a modified chunk
  • Return (nil, error) to send error to client and stop streaming
HTTPTransportPostHook is NOT called for streaming responses. Use HTTPTransportStreamChunkHook to intercept streaming data.
Header and Query Parameter Lookups: Use the case-insensitive helper methods for reading headers and query parameters:
The helper methods (CaseInsensitiveHeaderLookup and CaseInsensitiveQueryLookup) ensure your plugin works correctly regardless of how the client sends header/query parameter names.
These functions are only called when using bifrost-http. They are not invoked when using Bifrost as a Go SDK.

PreRequestHook(...) v1.6.x+

Called once per top-level request, before any provider call and before PreLLMHook. This is the routing phase: it’s where plugins decide which provider, model, and fallbacks the request should be sent to. Use this for:
  • Routing decisions: governance rules, virtual-key load balancing, geo/tier routing
  • Provider resolution: filling in req.Provider for unprefixed model names (the built-in model-catalog-resolver does this as the last routing layer)
  • Fallback chain construction: populating req.Fallbacks based on policy
Why a separate hook from PreLLMHook: Routing Example:
Two helpers worth knowing when writing routing logic:
  • ctx.AppendRoutingEngineLog(engine, level, message) — emits a structured log entry visible in observability tools. Use it to explain why a routing decision was made.
  • schemas.AppendToContextList(ctx, schemas.BifrostContextKeyRoutingEnginesUsed, engineName) — records which routing engine(s) participated. Surfaces in telemetry as routing.engines_used.
Plugin order matters for routing. Built-in routing plugins run in this order within PreRequestHook: governance routing rules → governance VK load balancing → enterprise load balancer → model-catalog-resolver (final fallback). Custom plugins can slot in via placement + order — see Plugin Sequencing.
Errors are non-blocking. Returning a non-nil error from PreRequestHook logs a warning but does NOT fail the request — the pipeline continues with the next plugin. The core validates req.Provider after all PreRequestHook plugins have run; an unresolved provider returns a 400 to the caller.

PreLLMHook(...)

Called before each provider request. Use this to:
  • Modify request parameters
  • Add logging or monitoring
  • Implement caching (check cache, return cached response)
  • Apply governance rules (rate limiting, budget checks)
  • Short-circuit to skip provider calls
Short-Circuiting Example:

PostLLMHook(...)

Called after provider responses (or short-circuits). Use this to:
  • Transform responses
  • Log response data
  • Store responses in cache
  • Handle errors or implement fallback logic
  • Add custom metadata
Response Transformation Example:

MCP plugin hooks overview v1.5.x+

Plugins can also intercept traffic going through the MCP gateway (Bifrost’s Model Context Protocol surface) by exporting any of four optional symbols. These are separate from the LLM hooks above — the LLM hooks fire for /v1/chat/completions / /v1/responses style traffic; the MCP hooks fire for ping / list_tools / execute_tool / connect operations on configured MCP clients. The MCP surface splits into two lifecycle stages with two hook pairs each: Connect carries transport-level inputs (URL, headers, stdio args) that don’t apply post-connection, so it’s handled separately by a typed interface. A plugin can implement both pairs, just the envelope pair, or just the Connect pair — export whichever symbols you need. The .so loader treats every MCP hook as optional.
Connect short-circuits use *MCPConnectionShortCircuit (typed Connect response). Envelope short-circuits use *MCPPluginShortCircuit (generic envelope wrapper). They look similar but are not interchangeable — Connect doesn’t go through the envelope path.

PreMCPConnectionHook(ctx, req) v1.5.x+

Runs when a configured MCP client’s transport is being brought up. Use this to:
  • Inject transport-level headers (HTTP/SSE clients)
  • Mutate StdioCommand / StdioArgs for stdio transports
  • Refuse a connection (short-circuit with an error) based on client name / auth type
Key points:
  • Mutable fields (changes are honored by Bifrost): ConnectionString, Headers, StdioCommand, StdioArgs
  • Observe-only fields (mutations are ignored): ClientName, ConnectionType, AuthType — changing them mid-flight would break the rest of the connect codepath
  • Headers mutations are silently dropped on STDIO and InProcess transports — check req.ConnectionType before injecting
  • Return (req, nil, nil) to continue with the (possibly mutated) request
  • Return (req, &MCPConnectionShortCircuit{Error: ...}, nil) to refuse the connection
  • Return (req, &MCPConnectionShortCircuit{Response: ...}, nil) to synthesize a successful handshake (rare — mostly for testing)

PostMCPConnectionHook(ctx, resp, bifrostErr) v1.5.x+

Runs after the upstream initialize handshake. Use this to:
  • Read ServerInfo, ProtocolVersion, and ServerCapabilities from the negotiated handshake
  • Gate downstream behavior on advertised capabilities (e.g. warn if a server doesn’t advertise Tools)
  • Transform handshake errors before they surface to the caller
Key points:
  • On handshake failure, resp is nil and bifrostErr is populated
  • Mutating resp is allowed but rarely useful — the handshake result is already cached by the connect pipeline
  • Return the values unchanged to pass through; return a modified bifrostErr to transform errors

PreMCPHook(ctx, req) v1.5.x+

Runs before each MCP envelope request (ping / list_tools / execute_tool). Use this to:
  • Apply per-tool allowlists or governance
  • Cache tool-list responses (short-circuit ListTools with a cached value)
  • Add request metadata to context for downstream PostMCPHook
Key points:
  • The envelope is request-type-discriminated via req.RequestTypealways early-out for non-tool-execute envelopes unless you specifically want to observe ping/list_tools. The req.RequestType.IsExecuteTool() helper exists for exactly this guard
  • For tool-execute requests, use req.GetToolName() to read the tool name regardless of which deprecated sub-request shape is populated (chat-tool-call vs responses-tool-call vs the future unified execute-tool)
  • Return (req, &MCPPluginShortCircuit{Error: ...}, nil) to block the call
  • Return (req, &MCPPluginShortCircuit{Response: ...}, nil) to short-circuit with a synthetic result

PostMCPHook(ctx, resp, bifrostErr) v1.5.x+

Runs after each MCP envelope request completes (successfully or with error). Use this to:
  • Record tool-call latency or usage metrics
  • Transform error responses
  • Update audit trails set by PreMCPHook via context
Key points:
  • Like Post LLM hook, runs in reverse order of pre-hooks across the plugin chain
  • The originating request type is available on the response via resp.ExtraFields.MCPRequestType (and on errors via bifrostErr.ExtraFields.MCPRequestType), so the same IsExecuteTool() early-out pattern works here too
  • Latency in milliseconds is on resp.ExtraFields.Latency
The full working example covering all four MCP hooks (envelope + Connect) lives at examples/plugins/mcp-only. Use it as a starting point rather than retyping the boilerplate.

Cleanup() error

Called on Bifrost shutdown. Use this to:
  • Close database connections
  • Flush buffers
  • Save state
  • Release resources

Step 3: Create a Makefile

Create a Makefile to automate building your plugin:

Step 4: Build Your Plugin

Build the plugin using the Makefile:
This creates build/my-plugin.so in your project directory. For production, you may need to build for specific platforms:
Cross-compilation doesn’t work for plugins! You must build on the target platform. If you need a Linux plugin, build it on a Linux machine or use Docker.

Step 5: Configure Bifrost to Load Your Plugin

Add your plugin to Bifrost’s config.json:

Plugin Configuration Options

  • enabled - Set to true to load the plugin
  • name - Plugin identifier (used in logs)
  • path - Absolute or relative path to the .so file
  • config - Plugin-specific configuration passed to Init()
  • version - (Optional) Plugin version number (default: 1). Increment this value to force a reload of the plugin and database update when Bifrost restarts. Useful when you want to ensure config changes take effect without manually clearing plugin state.

Step 6: Test Your Plugin

Start Bifrost and verify your plugin loads:
You should see output like:
Make a test request:
Check the logs for plugin hook calls:

Advanced Plugin Patterns

Stateful Plugins

For plugins that need to maintain state across requests:

Error Handling with Fallbacks

Control whether Bifrost should try fallback providers:

Caching Plugin Example

Troubleshooting

Plugin Fails to Load

Error: plugin: not a plugin file Solution: Ensure you built with -buildmode=plugin:

Version Mismatch Errors

Error: plugin was built with a different version of package Why this happens: Go’s plugin system requires exact version matching for:
  • The Go compiler version
  • All shared packages (especially github.com/maximhq/bifrost/core)
  • Transitive dependencies (packages that your dependencies depend on)
This is more strict than typical Go builds. Even if only one transitive dependency differs by a patch version, the plugin will fail to load. Solution: Ensure your plugin is built with the exact same versions as Bifrost. Step 1: Diagnose the mismatch Use go version -m to inspect the build info of both your plugin and the Bifrost binary:
Notice that even though the Go version matches (go1.26.1), the package versions are different - this causes the error. Step 2: Update your plugin dependencies
Step 3: Verify the fix
Pro tip: Pin exact versions in your go.mod and keep your plugin’s dependencies in sync with the Bifrost version you’re deploying. Consider building both Bifrost and your plugins in the same CI pipeline to guarantee version alignment.

Platform/Architecture Mismatch

Error: cannot load plugin built for GOOS=linux on darwin Solution: Build on the target platform or use the correct GOOS/GOARCH for your system.

Function Not Found

Error: plugin: symbol Init not found Solution: Ensure all required functions are exported (start with capital letter) and have the correct signature. The loader treats every hook as optional except GetName and Cleanup — including the MCP hook quartet (PreMCPHook, PostMCPHook, PreMCPConnectionHook, PostMCPConnectionHook). A typo in a hook name silently disables that hook rather than failing the load.

Source Code Reference

The complete hello-world example is available in the Bifrost repository:

Real-World Plugin Examples

Explore production-ready plugins in the Bifrost repository:

Frequently Asked Questions

Do I need to rebuild my plugin when upgrading Bifrost?

Yes, absolutely. Plugins must be compiled against the exact same version of github.com/maximhq/bifrost/core that Bifrost is using. This is a fundamental requirement of Go’s plugin system. When you upgrade Bifrost, you must:
  1. Update your plugin’s go.mod to use the matching core version
  2. Rebuild the plugin with the same Go version
  3. Redeploy the plugin alongside the new Bifrost version
Example: If upgrading from Bifrost v1.2.17 to v1.3.0:
Version mismatch will cause runtime errors! If your plugin is compiled with v1.2.17 but Bifrost is running v1.3.0, the plugin will fail to load with cryptic errors about package versions.

Should plugin builds be part of my deployment pipeline?

Yes, strongly recommended. Your plugin build and deployment should be tightly coupled with your Bifrost deployment. Recommended CI/CD Workflow:
Key Principles:
  1. Version Lock - Pin your plugin dependencies to specific Bifrost versions
  2. Atomic Deployment - Deploy Bifrost and plugins together as a single unit
  3. Build Verification - Test plugin loading as part of CI
  4. Rollback Strategy - Keep previous plugin versions for rollbacks

How do I handle plugin versioning in production?

Organize your plugin deployments by version:
This allows easy rollbacks:

Can I use different plugin versions for different Bifrost instances?

No. Each plugin must match the exact core version of the Bifrost instance loading it. If you’re running multiple Bifrost versions (e.g., staging vs production), you need separate plugin builds for each version.

What happens if I forget to rebuild a plugin?

You’ll see errors like:
Solution: Rebuild the plugin with the correct core version. See the Version Mismatch Errors troubleshooting section for detailed diagnosis steps using go version -m.

How do I test plugins before production deployment?

Multi-stage testing approach:
  1. Unit Tests - Test plugin logic in isolation
  2. Integration Tests - Load plugin in test Bifrost instance
  3. Staging Environment - Deploy to staging with production-like load
  4. Canary Deployment - Gradually roll out to production

Can I hot-reload plugins without restarting Bifrost?

Yes! Bifrost supports hot-reloading plugins at runtime. You can update plugin configurations or reload plugin code without restarting the entire Bifrost instance.

How do I debug plugin loading issues?

Enable verbose logging:
For verbose plugin-loader logs, set BIFROST_LOG_LEVEL=debug in the environment. Check plugin symbols:
Verify Go version:
Common debugging steps:
  1. Verify file exists and has correct permissions
  2. Check Go version matches Bifrost
  3. Confirm core package version matches
  4. Ensure all required symbols are exported
  5. Review Bifrost logs for detailed error messages

Need Help?