Overview
A plugin is a Go value that implementspluginapi.Plugin plus one or more
optional hook interfaces. Every guardrail is an
instance of a plugin, and so is a custom routing strategy for a
virtual model. There is one contract, in the
github.com/enterpilot/gomodel/pluginapi package, and three ways to ship a
plugin:
Whichever way it arrives, a plugin type shows up under its manifest name on
the dashboard’s Guardrails page, in
GET /admin/plugins, and as a type in
guardrails.rules.
pluginapi depends on the standard library only. A plugin imports it and
nothing else from GoModel.
Phases
A plugin declares which hooks it implements inManifest.Kinds. GoModel runs
the content phases from the matched workflow:
Which instance runs in which phase, and in what order, is a workflow decision
(
steps[].phase and steps[].step). The same instance may appear once per
phase.
Two more hook kinds exist in the contract but are not run by this release:
request (before model resolution) and complete (after the client response
is written). A manifest may declare them; GoModel validates that the
interfaces are implemented and ignores them at runtime. The route kind is
used by virtual models, see Routing strategy plugins.
Phases apply to /v1/chat/completions, /v1/responses, and /v1/messages.
Anthropic requests are translated to the chat form before any hook runs, so a
plugin sees one shape; Meta.Dialect reports anthropic_messages when it
matters. Inline /v1/batches items go through the prompt phase when
guardrails.enable_for_batch_processing is on.
Chain execution
Within one phase, instances are grouped bystep:
- Steps run in ascending order. A later step sees the edits of an earlier one.
- Within a step, instances whose manifest says
Mutates: falserun concurrently on a shallow copy of the exchange (theirValuesand response headers are merged back). The step may hold at most one instance withMutates: true, which runs after the readers. Two mutating instances at the same step is a configuration error. - Decisions of a step merge by severity:
block>respond>warn>allow. The first blocking decision ends the chain after its step.
Decisions
Every hook returns apluginapi.Decision:
respond is the “block with a safe message” most guardrail products offer:
agent loops keep running instead of surfacing a 4xx. Use block when the
caller should see an error.
Detail is stored in the audit record and must not contain secrets.
Prompt-phase decisions are appended to the request’s revision chain (the same
place request rewrites are recorded); response and stream decisions are
logged with the request id.
Fail modes and timeouts
Each instance has afail_mode and a timeout_ms:
The instance name of a fail-closed failure goes to the logs and the audit
record, never to the client. Panics inside a plugin are recovered and
treated as errors.
Timeouts are enforced at the deadline. A hook receives a context that
ends at
timeout_ms (and when the client disconnects); GoModel stops
waiting at that moment whether or not the hook has returned. A hook that
ignores its context keeps running in the background with no effect on the
request, so honour ctx.Done() in anything that blocks. An abandoned hook
counts as a failure. It fails open only when it could not touch what the
request continues with: a non-mutating hook runs on its own copy of the
exchange, which is discarded. A mutating hook, or an in-flight stream hook,
that outlives its deadline always fails the request, even with
fail_mode: open, because its edits can no longer be trusted. Init has a
fixed 10 s deadline handled the same way: an Init that has not returned by
then fails the instance.
Keep the default. Switch an instance to open only when it is an observer
whose absence is acceptable (a header tagger, a warn-only classifier).
Instance lifecycle
GoModel builds one plugin value per guardrail definition and callsInit
once. Definitions are reloaded from storage periodically
(workflows.refresh_interval, default 1 minute) and on every admin change;
an instance whose type, config, user_path, fail_mode, and timeout_ms
are unchanged survives the reload, so state a plugin accumulates (counters,
caches, connections) is kept. A replaced or deleted instance is closed two
refresh intervals later, once compiled workflows have been rebuilt against
the new set and in-flight requests have finished. Every instance is closed
on shutdown. Close may therefore run while a late request still holds the
instance: release resources, do not panic on a call after Close.
Streaming
Bytes already sent to the client cannot be recalled, so a stream plugin picks one of three modes inStreamHook.StreamPolicy():
Lookbehind (
transform only): GoModel withholds the last LookbehindChars
characters of text per choice and presents each delta together with that
tail, so a pattern spanning two chunks (a phone number, an API key) is
visible in one event before any of it reaches the client. A pattern of up to
LookbehindChars + 1 characters is always caught. 0 disables it.
Buffering: the upstream is drained into a bounded buffer
(MaxBufferBytes, default 4 MiB; exceeding it fails closed with code
response_too_large). While draining, GoModel sends the SSE comment
: gomodel-buffering every 15 s so proxies and clients do not time out; SDKs
ignore comment lines. After the response chain runs, the original bytes are
replayed unchanged (allow, warn), a stream is synthesized from the edited
completion (allow with edits, respond), or a single error chunk is sent
(block).
Mixed chains: if any stream instance asks for buffer, or the workflow has
any response step, the whole stream is buffered and transform instances run
over the replay.
Warnings on streams: when response or stream plugins run, GoModel commits
the HTTP headers with the first bytes of the body rather than up front, so a
warn decided over a buffered response reaches the client as the
X-GoModel-Guardrail header as long as buffering finished before the first
keep-alive comment (15 s). A warn decided later (in OnStreamEnd of a
transform stream, or after a keep-alive went out) cannot change headers
already sent; it is still recorded in the audit trail and the logs.
Cutting a stream: a terminate decision in transform mode, or a
block/respond from OnStreamEnd, ends the stream with
finish_reason: "content_filter" (stop_reason: "end_turn" for Anthropic)
followed by [DONE]. The client keeps what it already received. If nothing
may leak before the decision, the plugin must use buffer mode; the built-in
string_replace and llm_judge do exactly that for block and respond.
The Exchange
Every hook receives one*pluginapi.Exchange, the same object in every phase
of a request:
Edits go through methods, not field assignment, so GoModel re-encodes only
what changed and untouched messages keep every provider-specific field
(
cache_control, multi-part content, extra fields). Removing a message that
carries a tool call whose result is still present (or the reverse) returns a
DanglingToolError naming the partner message; the request is rejected if
the pair stays broken.
Meta.Cache.PlannedPrefixMessages tells a prompt plugin how many leading
messages the provider cache planner will mark as the cached prefix. Appending
keeps the cache; editing inside the prefix invalidates it for the session.
Configuration
Instances
Instances are declared inguardrails.rules or created on the dashboard;
both land in the same store. See Guardrails for the
per-type settings.
config.yaml are seeded into the managed default workflow at
order in phase. type accepts an optional plugin: prefix.
Loading .so files
file must resolve inside one of the search_paths (symlinks are
followed and checked). A file that cannot be resolved, verified, or opened is
a startup error naming the file.
GUARDRAILS_ENABLED remains the switch that decides whether configured
instances run on traffic.
Dashboard
- Guardrails page: Create Guardrail lists every plugin that implements a
prompt,response, orstreamhook. The form is generated from the plugin’sConfigSchema:text,textarea,number,select,checkboxes,secret(masked;GETreturns********and sending that literal back keeps the stored value), andmodel(a provider/model or alias picker). The Advanced section holds Fail mode (Default, Closed, Open) and Timeout (ms); they apply to the instance in every workflow that references it. - Plugins list: at the bottom of the Guardrails page, every loaded plugin
type with its version, hooks, source (built-in, registered, or the
.sopath), and health. A plugin that failed to load is listed with its error. - Workflows page: each step has a Phase selector; the instance dropdown only offers instances whose plugin implements that phase.
Shared-object plugins
Go’splugin package sets the rules, and they are strict:
- Platform: Linux, macOS, and FreeBSD, and only in a binary built with
CGO_ENABLED=1. The default GoModel binary and image are static and refuse.sofiles with an error that says so. Usemake build-plugins(bin/gomodel-plugins) or thegomodel:<version>-pluginsimage (make image-plugins, built fromDockerfile.plugins, glibc runtime). - Exact toolchain: the plugin must be built with the same Go version,
the same build flags (
-trimpath,-race,-tags), and identical sources of every shared package. Only the standard library andpluginapiare shared, so internal GoModel changes never affect a plugin, but every GoModel release and every Go toolchain update (patch releases included) needs a rebuild. Make it a CI step. - No unload: a
.sostays loaded until restart. Changing a loaded file takes effect on restart; editing an instance’s config re-runsIniton a fresh value without a restart. - Trusted code: loading a
.sois equivalent to changing the binary. Keepsearch_pathsroot-owned and pinsha256in production.
Build and inspect
plugin build runs go build -buildmode=plugin with the flags recorded in
the gomodel binary that runs it, forces CGO_ENABLED=1, pins GOTOOLCHAIN
to the host’s Go version, stamps a GoModelBuildInfo variable into the
plugin, and refuses an output whose Go version differs from the host’s.
Always build with the binary that will load the plugin. plugin inspect
opens the file and prints its manifest, config schema, and build info. A
refused load names both sides:
plugin-builder target of Dockerfile.plugins and run
it against your plugin directory:
gomodel --version prints the Go and pluginapi versions of the binary.
examples/plugins/README.md covers building a plugin in its own Go module.
Writing a plugin
Apackage main that exports func GoModelPlugin() pluginapi.Plugin is a
shared-object plugin; the same type registered with ext.RegisterPlugin is a
compiled-in one. This one blocks a request when the last user message
contains a configured word:
Initreceives the config after validation againstConfigSchema: defaults applied, numbers and lists coerced, unknown keys rejected, required keys checked. Line-orientedtextareafields accept a YAML list of strings as well as a block scalar. It is called once per configured instance and again when the instance is edited.- Export a constructor (
func GoModelPlugin() pluginapi.Plugin) so one file can back several instances. Avar GoModelPlugin pluginapi.Pluginworks too but limits the file to a single instance. - Set
Mutates: truewhen the plugin editsPrompt,Response, or the stream. Non-mutating plugins may share a step and run concurrently. - The
Hostpassed toInitoffersLogger()(pre-tagged with plugin and instance),Inference().Complete(...)(a chat completion through the gateway with originplugin, scoped to<user path>/guardrails/<instance>for budgets and audit), andMetrics()(names prefixedplugin_<name>_).History()returns an error in this release. - Add
Summarize(config json.RawMessage) stringto render a one-line summary in the guardrails list, andNormalize(config) (json.RawMessage, error)to canonicalize a config before it is stored. Both are optional. - Field inputs
secretandmodelexist for API keys of external classifiers and for model pickers; asecretvalue is masked in every admin response. - Add
Scope: pluginapi.ScopeRouteto fields that belong to a virtual model rather than to the instance (routing strategies only).
examples/plugins/keywordblock/main.go is a fuller, commented example with
prompt and response hooks, and the built-ins under
internal/plugins/builtin/ are reference implementations of every hook kind.
Routing strategy plugins
A plugin that implementspluginapi.RouteStrategy is a load-balancing
strategy for virtual models:
RouteRequest carries the virtual model Source, the Candidates in
configured order (provider, model, provider/model, weight, and prices per
million tokens when known), the session id and sticky SessionTarget,
Meta, and the virtual model’s strategy_config as JSON (Prompt is nil
in this release). Select returns the chosen Qualified target and an
optional Reason (debug logs only); OnAttemptEnd reports success, status,
latency, and timeouts so the strategy can adapt. Failover chains, capacity
probes, and session pinning stay GoModel’s job.
Select the strategy on the virtual model:
strategy_configis validated against the plugin’s route-scoped fields (route_fieldsinGET /admin/plugins).strategy: pluginwithoutstrategy_pluginis a validation error, andPUT /admin/virtual-modelsrejects a name that is not a loaded plugin with aroutehook.- Target
weightis ignored under the plugin strategy. - Instance-scoped fields of a route plugin (an endpoint, an API key) come
from a guardrail definition whose name and type both equal the plugin
name; without one the plugin is initialized with
{}. Instance-scopedsecretvalues currently reach a route plugin redacted. - GoModel falls back to weighted round robin, logging one warning per
virtual model, when the plugin is missing, is not a route plugin, failed to
initialize,
strategy_configis invalid, orSelecterrors, panics, takes longer than 250 ms, or returns a target outside the viable pool.
plugin:<name> entry per loaded route plugin (VIRTUAL_MODEL_STRATEGIES in
the runtime config is round_robin,cost,failover[,adaptive],plugin:<name>,...);
choosing it renders the plugin’s route fields under the targets table.
cheapest_healthy is the built-in reference strategy
(internal/plugins/builtin/routeexample). It keeps the last 50 outcomes per
target and picks the cheapest (prefer: cheapest, the default) or the
lowest-median-latency (prefer: fastest) target whose error rate is at or
below max_error_rate (default 0.2), keeps a healthy SessionTarget, and
considers every candidate when none is healthy.
Admin endpoints
All endpoints require the same admin credentials as the rest of/admin.
GET /admin/plugins
Every plugin type known to the gateway, including ones that failed to load:source is builtin, registered (compiled in through ext), or the
absolute path of the .so. fields are the instance-scoped schema fields,
route_fields the route-scoped ones. A .so built with gomodel plugin build also reports built_with (Go and pluginapi versions).
GET /admin/guardrails/types
One entry per plugin that implements aprompt, response, or stream
hook, in the shape the guardrail editor renders:
phases, source, and mutates are new; fields[].input may now be
secret or model, and fields[].scope is "" (instance) or "route".
GET, PUT, DELETE /admin/guardrails
Definitions gainfail_mode (closed | open, empty for the phase default)
and timeout_ms (0 for none); views additionally carry phases (from the
plugin manifest) and summary. PUT accepts both new fields. A stored
secret value is returned as ********; send it back unchanged to keep it,
or "" to clear it.
GET /admin/workflows/guardrails
The instances available to workflow steps, with the phases each supports:Workflow payload version 2
POST /admin/workflows accepts schema_version: 2 with steps[] carrying a
phase; version 1 payloads (guardrails: [{ref, step}]) stay valid and are
returned unchanged. Workflow views expose the per-phase chain hashes as
chain_hashes. See Workflows.