Plugin and Extension Systems in Go#

Summary#

Across 51 Go projects, plugin and extension systems fall into seven distinct mechanism categories, from in-process init() registries to out-of-process gRPC subprocess protocols. The dominant pattern is compile-time interface composition — over half the corpus extends behavior via Go interfaces wired at startup, with no binary boundary. Only a small cluster of infrastructure tools (Vault, Terraform, Grafana, Nomad) crosses the process boundary. The most architecturally sophisticated systems are Caddy (in-process namespace registry), Traefik (Yaegi interpreter + WASM), and Kubernetes (multi-layered gRPC plugin interfaces). The majority of projects — web frameworks, CLI tools, databases — never need true runtime plugin loading.


Taxonomy#

Approach 1: Subprocess + gRPC (hashicorp/go-plugin)#

The gold standard for trusted-but-isolated third-party code. A plugin binary is launched as a subprocess; communication happens over a loopback mTLS gRPC connection with a magic-cookie handshake.

  • Projects using it: vault, terraform, grafana, nomad

  • How it works: The host process calls go-plugin.Client.Start() which forks the plugin binary, negotiates a protocol version via stdout/stdin, and establishes a gRPC channel. The plugin binary calls go-plugin.Serve(). Both sides share proto-generated interfaces; the host can’t tell whether a plugin is in-process or out-of-process.

    • Vault: logical.Backend interface (auth methods, secret engines). Single binary can multiplex multiple mount instances since Vault 1.12. Plugin catalog stores SHA256 hashes for verification.
    • Terraform: tfplugin5.proto / tfplugin6.proto define service Provider (~30 RPCs). Providers are discovered via module registry, filesystem, or dev override. Dual protocol versions coexist; Protocol 6 adds features while Protocol 5 remains for backward compatibility.
    • Grafana: pluginv2 gRPC protocol (QueryData, CallResource, CheckHealth, etc.). Plugin processes are discovered by scanning filesystem for plugin.json manifests; signature verification blocks unsigned plugins by default. The Grafana Plugin SDK (grafana-plugin-sdk-go) is a separate published module for plugin authors.
    • Nomad: Task drivers (Docker, QEMU, Java, raw_exec) expose the DriverPlugin interface via go-plugin. Fingerprinting, task lifecycle, network namespace isolation — all plugin responsibilities.
  • When it’s appropriate: When plugin code is third-party and untrusted, when plugins should crash-isolate from the host, when plugins need independent upgrade cycles, or when you want language-agnostic extensibility (compile the plugin in any language that produces a gRPC server).

  • Key tradeoff: Subprocess overhead (~5ms launch, ~1ms IPC round trip) is acceptable for infrastructure operations but prohibitive for request-level hot paths.


Approach 2: In-Process init() Registry#

Plugins register themselves by calling a global registration function from their init() function. The main binary blank-imports all plugin packages. No subprocess, no gRPC; the plugin runs in the host process.

  • Projects using it: caddy, rclone, prometheus (exporters)

  • How it works:

    • Caddy: caddy.RegisterModule(m Module) called from init(). Module.CaddyModule() returns a ModuleInfo with a dotted namespace ID (e.g., http.handlers.reverse_proxy) and a constructor func() any. The namespace determines which parent config key loads the module and which interface it must implement. A list-modules command enumerates all registered modules.
    • Rclone: fs.Register(&fs.RegInfo{Name: "s3", ...}) called from init(). Each backend’s RegInfo declares its options as []fs.Option, which auto-surface as --s3-* flags. All backends are linked in via backend/all/all.go blank imports.
    • Prometheus: promauto.NewGaugeVec(...) and prometheus.MustRegister(...) from init() register metrics collectors into a global default registry. Not a “plugin” in the functional sense, but uses the same init()-based self-registration pattern.
  • When it’s appropriate: When you own all the plugin code (or accept that plugins require recompilation), when you want zero IPC overhead, and when plugin authors are Go developers who can ship a custom build. Rclone explicitly acknowledges this: all backends are compile-time only.

  • Key tradeoff: Extensibility requires a recompile. Caddy addresses this with xcaddy, a build-time tool that downloads and compiles plugin packages into a custom Caddy binary. The runtime module registry becomes a compile-time composition mechanism.


Approach 3: Interpreted Runtime (Source Code at Runtime)#

Instead of compiled binaries, plugins are source code (Go or JS) loaded and executed at runtime by an embedded interpreter.

  • Projects using it: traefik (Yaegi + WASM), pocketbase (goja/JavaScript)

  • How it works:

    • Traefik / Yaegi: github.com/traefik/yaegi is a Go interpreter. Plugins are .go source files; Traefik parses and interprets them at startup. No compilation step for the user. The plugin manifest (.traefik.yml) declares module path and entry point. Remote plugins are fetched from plugins.traefik.io as source archives. WASM plugins via wazero offer language-agnostic alternatives with opt-in syscall/filesystem access.
    • PocketBase / goja: pb_hooks/*.pb.js or *.pb.ts files are loaded into a pool of pre-warmed goja.Runtime instances. The JS runtime exposes the full Go hook API — onRecordCreateRequest(fn), routerAdd(...), etc. TypeScript type definitions are pre-generated for IDE autocomplete. This makes PocketBase extensible for non-Go developers without requiring a build toolchain.
  • When it’s appropriate: When you want hot-reload without recompilation, when you target non-Go developers, or when you want to distribute plugins as source (auditable, version-controlled). Yaegi specifically targets the “I want to write a Go plugin but can’t require users to compile Caddy” use case — the Go developer experience without the build step.

  • Key tradeoff: Interpreter performance (Yaegi: ~5–10× slower than compiled Go; goja: ~10–20× slower than V8). Interpreter security surface (Yaegi runs in the same process; WASM adds isolation). Yaegi’s Go compatibility is not 100% — some reflection-heavy patterns don’t work.


Approach 4: Hook/Lifecycle Callback Pipeline#

Extension points are lifecycle events. Code registers callbacks that fire before, during, or after specific operations.

  • Projects using it: pocketbase (primary extension), gorm (callbacks), kubernetes (admission webhooks — out-of-process variant)

  • How it works:

    • PocketBase: tools/hook.Hook[T Resolver] — a generic type-parameterized middleware chain. Hooks are registered with app.OnRecordCreate().BindFunc(...) before app.Start(). Each Hook[T] is a priority-ordered list of Handler[T] instances; calling e.Next() advances the chain. The same hook API works for Go library users and for JSVM-based JS hooks.
    • GORM: db.Callback().Create().Before("gorm:create").Register("myapp:audit", fn) — a named pipeline per operation type (Create, Query, Update, Delete, Row, Raw). Ordering is declared by name dependency (Before/After), not by integer priority. Model-level BeforeCreate/AfterCreate struct methods are auto-discovered via schema reflection and registered as callbacks automatically.
    • Kubernetes (webhooks): MutatingWebhookConfiguration / ValidatingWebhookConfiguration register HTTPS URLs. The API server calls these URLs for matching resource operations. This is the out-of-process variant of the hook pattern — handlers are external HTTP services rather than in-process callbacks. Runs after auth, before persistence.
  • When it’s appropriate: When you want to add cross-cutting concerns (audit, validation, enrichment) to an existing data flow without modifying core logic. GORM’s callback system is the cleanest example in the corpus — it gives plugin authors a stable ordered pipeline with named insertion points.

  • Key tradeoff: Ordering complexity grows with number of hooks. PocketBase’s priority integer approach is simpler to reason about than GORM’s name-dependency DAG, but both work. The Kubernetes webhook variant adds network latency (typically 1–5ms per admission call) but achieves complete language and deployment isolation.


Approach 5: Language-Agnostic gRPC Protocol Sockets#

Components expose a gRPC server on a Unix domain socket or TCP address. The host discovers and connects to them; no process management. This is “pluggable components” as a deployment pattern, not a code-loading pattern.

  • Projects using it: dapr, kubernetes (CRI, Device Plugin, CSI, DRA, KMS)

  • How it works:

    • Dapr: Pluggable components implement proto-defined gRPC services (StateStore, PubSub, InputBinding, OutputBinding, SecretStore) and listen on a Unix domain socket (in Kubernetes) or TCP address. Dapr discovers them by reading component YAML files at startup. A Python Redis state store, a Rust S3 binding, or a Java Kafka pub/sub are all equally valid.
    • Kubernetes: CRI (Container Runtime Interface), Device Plugin API, CSI (Container Storage Interface), DRA (Dynamic Resource Allocation), KMS (Key Management Service) all define gRPC service contracts. Implementations (containerd, Nvidia GPU plugin, aws-ebs-csi-driver, etc.) listen on known Unix socket paths; kubelet connects to them at node startup. Plugin registration happens via a separate Registration service that components call to announce themselves.
  • When it’s appropriate: When you want maximum language diversity in plugins, when plugin lifecycle is managed externally (Kubernetes manages component Pod lifecycle), or when you want zero coupling between host and plugin build toolchains. This is “microservices as plugins.”

  • Key tradeoff: Operational complexity — each component is a separately deployed process. Discovery requires explicit configuration (Dapr component YAML) or well-known socket paths (Kubernetes). Network/socket overhead exists even for local calls.


Approach 6: Interface Injection and Middleware Chains#

Extension points are Go interfaces wired at construction time. Middleware chains allow inserting func(next Handler) Handler wrappers. No subprocess, no registry, no dynamic loading.

  • Projects using it: echo, gin, fiber, beego, buffalo, traefik (providers + middleware), caddy (per-namespace interface contracts), chi (used by drone, pocketbase, dapr)

  • How it works:

    • Echo: Five interfaces are swappable at NewWithConfig(echo.Config{Router: myRouter, Binder: myBinder, ...}). The middleware chain is built by e.Use(m1, m2) + per-route e.GET("/path", handler, authMiddleware).
    • Gin: binding.Validator (package-level var) can be replaced for a different validation engine. HandlerFunc variadic chain — handlers and middleware are the same type, composable via Use() and inline at route registration.
    • Traefik providers: The Provider interface (Init() error, Provide(chan<- dynamic.Message, *safe.Pool) error) is how all dynamic config sources (Docker, Kubernetes, Consul, File, HTTP, custom plugins) hook in. The provider aggregator accepts any implementation.
    • GORM: The Plugin interface (Name() string, Initialize(*DB) error) is registered via db.Use(myPlugin). Plugins initialize themselves by registering callbacks — the plugin pattern is layered on top of the callback pattern.
  • When it’s appropriate: For frameworks and libraries where all consumers are Go developers, where compile-time type safety matters more than runtime extensibility, and where the “plugin” is really a strategy or policy that the consumer controls. HTTP frameworks live here permanently.

  • Key tradeoff: No hot-reload, no independent deployment, requires recompilation. But also: zero overhead, full type safety, no serialization boundary. For frameworks, this is the right answer.


Approach 7: Binary PATH Discovery (No Protocol)#

Plugin executables are discovered by convention on $PATH or in a designated directory. The host invokes them as subprocesses; communication is stdin/stdout or via environment variables. No gRPC, no proto.

  • Projects using it: kubectl (kubectl-* plugins), helm (helm-* plugins), hugo (external markup converters: asciidoctor, pandoc, rst2html)

  • How it works:

    • kubectl: kubectl plugin list scans $PATH for executables named kubectl-*. They are invoked as subprocesses with the same arguments the user typed after kubectl plugin. No registration, no version negotiation, no protocol — pure convention.
    • Helm: $HELM_PLUGINS directory contains subdirectories with a plugin.yaml manifest. helm plugin install <url> downloads and installs them. Plugins receive command arguments and the HELM_PLUGIN_DIR env var.
    • Hugo: No Go plugin interface for markup — instead, Hugo invokes external binaries (asciidoctor, pandoc, rst2html) as subprocesses when the relevant converter is configured. stdin/stdout protocol. The Go side is just os/exec.
  • When it’s appropriate: When CLI extensibility is desired, when plugins are shell scripts or binaries in any language, and when the use case is “run arbitrary code as a subcommand.” The simplest possible plugin model.

  • Key tradeoff: No type safety, no version negotiation, no error protocol beyond exit codes. Works well for CLI commands; terrible for data-intensive or latency-sensitive operations.


Projects Without a Plugin System#

The majority of the corpus has no plugin/extension system beyond Go interface composition:

ProjectExtension modelNotes
etcdNoneWatches + leases are the extension model; consumers implement their own logic
prometheusinit() metric registrationExtensibility is at the library consumer level, not plugin loading
minioCompile-time backendsS3-compatible; extensions are adapters, not plugins
cockroachNoneAll functionality is built in; no external plugin protocol
consulCompile-time backendsService discovery strategies are compiled in
k3sKubernetes plugin contracts (CRI/CNI/CSI)K3s uses containerd + flannel/calico — no new plugin protocol
istioEnvoy Wasm extensionsThe Go layer has no plugin system; Envoy filters handle extensibility
argo-cdCRD + webhookExtensibility through Kubernetes primitives
tekton-pipelineCRD (Tasks, Steps)Pipeline steps are container images, not Go plugins
syncthingNoneCompile-time device discovery
resticCompile-time backendsRepository backends are compiled in
nats-serverNoneConfiguration-driven; leaf nodes and clustering are built-in
temporalWorkflow code IS the pluginWorkers execute user-defined Go code; no plugin loading per se
fzfNonePure algorithm; no extension points
ghPATH-based extensions (gh extension)Similar to kubectl-* pattern
gitea / gogsWebhooks + Git hooksExtension via HTTP callbacks, not Go plugins
buildkite-agentPlugin steps via DockerCI steps are containers, not Go plugins
airNoneFile-watcher utility
pop / delveNoneCLI tools
frp / headscale / tailscale / wireguard-goNoneNetwork tools; compile-time feature selection
cobra / viper / sqlcNoneLibraries with no plugin model
crushNoneTUI app
fyneNone (renderer backend is compile-time)GUI framework; backends (OpenGL, WASM) are build-tag selected

Comparison Dimensions#

Extension Point Naming and Identity#

ProjectIdentity schemeExample
CaddyDotted namespace string"http.handlers.reverse_proxy"
RcloneShort string name"s3", "sftp"
VaultType + namelogical.TypeLogical + "kv"
TerraformProvider address"registry.terraform.io/hashicorp/aws"
GrafanaPlugin ID string"grafana-piechart-panel"
DaprYAML component typestate.redis/v1
GORMString name"myapp:before-create"
PocketBaseGo type or hook nameOnRecordCreate
KubernetesGVK (Group/Version/Kind)apps/v1/Deployment
TraefikYAML key + middleware nametype: basicAuth, name in config

The Caddy dotted-namespace scheme is the most elegant: the namespace encodes both where in the config JSON tree the module appears (http.handlers.*) and which interface it must implement. New namespaces can be added by any module without modifying Caddy core — the parent module just looks for children in its namespace using ctx.LoadModule().

Registration Timing#

PatternProjectsNotes
init() (process startup, before main)caddy, rclone, prometheus, gin, echo, fiberOrder-independent; global registry
Constructor injection (New() or config struct)echo, gin, fyne, beegoExplicit, testable, no global state
Boot-time Use()/Register() callgorm, pocketbase, dapr, vaultAfter init, before start; explicit ordering
Runtime (config reload / hot add)caddy (reload), vault (mount at runtime), grafana (plugin install)Varies; caddy reloads config without process restart
Build-time (blank import)rclone (backends), caddy (built-in modules)Compile-time only

Security Model Comparison#

ProjectIsolationTrust model
vaultSubprocess + mTLSSHA256 hash verification of plugin binary
terraformSubprocess + mTLSRegistry signature verification
grafanaSubprocess + mTLSSignature verification; unsigned plugins blocked by default
traefik (WASM)wazero sandboxOpt-in env/filesystem access
traefik (Yaegi)Same processNo sandbox; Yaegi code runs with host privileges
daprUnix socketComponent process isolated; socket ACLs
kubernetes CRI/DeviceUnix socket + registrationNode-level; kubelet trusts registered components
caddySame processTrust the module author (recompile model)
pocketbase (JSVM)Same process (goja)JS cannot do syscalls; Go hook API is the boundary
kubectl pluginsSubprocessNo verification; PATH-based trust

The go-plugin + mTLS + hash-verification stack (Vault/Terraform/Grafana) provides the strongest security guarantees in the corpus for third-party untrusted plugins. The Yaegi approach is the weakest — interpreted Go code runs with full host privileges, making it appropriate only for trusted or self-authored plugins.


Common Patterns#

  1. The init() self-registration idiom is nearly universal for in-process extensions. Projects as different as Caddy (modules), Rclone (backends), and Prometheus (metrics) all use it. The mechanism is idiomatic Go: import the package, the package registers itself.

  2. Interface as contract. Every plugin system ultimately defines an interface (or proto service) that plugins must implement. The richness of the interface correlates with the complexity of the domain: GORM’s Plugin interface has 2 methods; Vault’s logical.Backend has 12+; Terraform’s providers.Interface has 30+.

  3. Registry + factory pattern. Most systems store a map[string]func() Interface — a named factory registry. The key identifies the plugin type; the value is a constructor called at instantiation time (to create per-mount/per-route instances). This pattern appears in Caddy (ModuleInfo.New), Vault (CoreConfig.LogicalBackends), Rclone (fs.Registry), and GORM (Config.Plugins).

  4. Namespace scoping. Caddy’s dotted namespace (e.g., http.handlers.*) is the most sophisticated example of scoping extension points. Dapr’s state.redis/v1 component type string serves a similar function. Kubernetes GVK is the most elaborate namespace in the corpus.

  5. Middleware chain as the simplest plugin system. Every HTTP framework provides func(next Handler) Handler chaining. This is the most widely used extension mechanism in the corpus — virtually every project serving HTTP uses it. It’s the lowest-complexity plugin system that still enables meaningful cross-cutting extensibility.

  6. Proto as the stable ABI. Projects crossing process boundaries (Vault, Terraform, Grafana, Dapr, Kubernetes) all use protobuf as the serialization format. Proto provides language neutrality and backward-compatible schema evolution. The absence of proto correlates with same-process extension (Caddy, Rclone, Echo).


Divergent Choices#

Where they differ most#

Process boundary vs. same-process: The sharpest divide is between infrastructure tools that cross the process boundary (Vault, Terraform, Grafana, Nomad) and everything else. The infrastructure tools have explicit requirements: third-party trust boundaries, crash isolation, independent upgrade cycles, multi-language plugin authors. Web frameworks have none of these requirements.

Source code vs. binary: Traefik’s Yaegi approach (interpret Go source at runtime) is unique in the corpus. No other project interprets the host language at runtime for plugins. This choice stems from Traefik’s desire to offer a plugin marketplace (plugins.traefik.io) with user-authored middleware, but without requiring users to compile a custom binary. PocketBase’s goja (JS) choice is similar in motivation but targets a different developer audience.

Namespace granularity: Caddy’s namespace system is the most granular — it partitions Caddy’s config JSON into subtrees, each with its own interface requirement. Vault uses a coarser type taxonomy (auth method, secret engine, audit backend). Rclone treats all backends as equivalent. The finer the namespace, the more targeted the interface requirements, but also the more surface area to understand.

Runtime vs. compile-time: Caddy’s xcaddy tool and Grafana’s plugin installer (grafana cli plugins install) represent two philosophies for “deploy a new plugin at runtime”: xcaddy recompiles the entire binary; Grafana downloads and starts a new subprocess. The binary recompile model trades deploy time for security and performance; the subprocess model trades isolation and operational overhead for convenience.


  1. Size → complexity of plugin system. XL projects (Kubernetes, Grafana, Vault, Terraform) all have rich out-of-process or multi-layered plugin systems. S/M projects (fzf, air, cobra, viper) have none. The correlation is strong: 0/16 small projects have a true plugin system; 8/8 large infrastructure tools do.

  2. Domain → mechanism choice. Infrastructure and platform tools gravitate toward subprocess + gRPC (security, isolation, multi-language). Developer tools and frameworks gravitate toward in-process interface composition (performance, simplicity). Content and CLI tools may have no plugin system at all.

  3. Age and the init() pattern. Older Go projects (rclone, prometheus, caddy) use init() self-registration heavily. Newer projects (pocketbase, dapr) prefer explicit app.Use() / config-file component declaration, avoiding the implicit global state of init().

  4. gRPC as the universal IPC language. All projects that cross a process boundary for plugins use gRPC. None use REST, none use JSON over stdin/stdout (except kubectl/helm’s trivial pattern). gRPC’s generated types, streaming support, and bidirectional calls make it the clear winner for plugin IPC.

  5. Typed registries over stringly-typed configs. The best plugin systems have a typed registry (map[string]func() Interface) rather than just a string key pointing to a config blob. Caddy’s ModuleInfo.ID + ModuleInfo.New is typed; Dapr’s component YAML type: state.redis/v1 is stringly-typed until loaded.


Best Practices#

Synthesized from the projects with the most effective plugin systems:

  1. Define a narrow interface. GORM’s Plugin (2 methods) and Caddy’s Module (1 method) are minimal entry points. Vault’s logical.Backend (12 methods) is wider but justified by the domain. Resist adding methods to the plugin interface that aren’t strictly required — each method is a burden on all plugin authors.

  2. Separate instantiation from configuration. Caddy’s CaddyModule().New returns a fresh empty struct; configuration is applied via Provision(Context) called separately. This lets the registry create instances before config is available, and supports config reload by creating new instances rather than mutating live ones.

  3. Name your callbacks. GORM requires every callback to have a name ("myapp:before-create"). Names enable Remove(name) and Replace(name, fn) — plugins can override each other. Anonymous callbacks cannot be targeted.

  4. Verify plugins before running them. Vault and Terraform store SHA256 hashes of plugin binaries in their catalogs. Grafana requires cryptographic signatures for plugins in the official catalog. Unsigned plugins should at minimum emit a warning. The kubectl/helm model (any binary on PATH) is appropriate only when the user explicitly opted in.

  5. Give plugins access to system context, not raw pointers. Caddy’s caddy.Context (passed to Provision()) provides ctx.App(name), ctx.LoadModule(), ctx.Logger() — scoped access to the host. Vault’s SystemView (via gRPC callback) provides similar scoped access to secret system state. This keeps plugin coupling at the interface level, not the implementation level.

  6. Support lifecycle hooks. Caddy’s Provisioner, Validator, CleanerUpper interfaces let modules opt into lifecycle phases they need. GORM’s BeforeCreate/AfterCreate methods on model structs do the same. Requiring all plugins to implement all lifecycle methods creates unnecessary burden; optional lifecycle interfaces are the right pattern.

  7. Use dotted namespaces for complex systems. Caddy’s namespace scheme (http.handlers.*, tls.certificates.*, admin.api.*) maps directly to the config JSON tree. Adding a new namespace requires no core change. This is scalable in a way that a flat type-string registry isn’t.


Anti-Patterns#

  1. Global mutable init() registry with no ordering guarantees. The init() pattern is safe for registration (just appends to a map) but dangerous if plugins depend on each other’s initialization order. Caddy solves this via lazy Provision() calls; rclone’s backends are stateless so order doesn’t matter. A project that needs ordered init should use explicit Use() calls instead.

  2. Plugin interfaces with too many methods. Terraform’s providers.Interface has 30+ methods. This creates a high burden on both implementations and test doubles. Grafana’s PluginContextProvider interface has grown over time to include optional capabilities that should have been separate optional interfaces.

  3. Interpreted plugins in the same process without sandboxing. Traefik’s Yaegi plugins run with full host process privileges. A malicious Yaegi plugin can read environment variables, open network connections, and call arbitrary Go code. The Yaegi approach is sound for trusted/self-authored plugins but should not be used for a public marketplace of unknown third-party code.

  4. Stringly-typed plugin activation. Consul’s and Nomad’s plugin types use string keys in config files without schema validation. A typo in "stype": "seals" silently falls back to default. The best systems (Vault, Terraform) validate plugin types at configuration parse time.

  5. No version negotiation. kubectl’s PATH-based plugin model has zero version negotiation. The host and plugin must be compatible by convention. Traefik’s plugin manifest declares a version; Terraform’s plugin protocol has explicit GetMetadata capability exchange. For anything more complex than CLI commands, version negotiation is required.


Exemplars#

Caddy — best in-process plugin system in the corpus. The namespace-keyed module registry, lifecycle interfaces (Provisioner, Validator, CleanerUpper), scoped context injection, and Caddyfile directive registration all work together. The xcaddy build tool solves “how do users add plugins without maintaining a fork” elegantly. If you’re building an extensible server in Go and don’t need subprocess isolation, copy this design.

Vault — best out-of-process plugin system in the corpus. The logical.Backend interface is narrow enough to implement; the go-plugin + mTLS + SHA256 hash stack provides genuine security isolation; the in-process/out-of-process duality (logical.Backend is the same Go interface regardless of how it’s served) keeps the core architecture clean. Vault’s plugin catalog (operator-managed registry with binary fingerprints) is the right operational model for production infrastructure.

PocketBase — best hook-based + scripted extension system. The generic tools/hook.Hook[T] is reusable across all lifecycle events; the JSVM layer exposes the same hook API to non-Go developers without requiring a build step. The OnServe hook + e.Router access pattern lets Go library users extend PocketBase’s HTTP API with the full middleware stack automatically applied — there’s no “plugin API” separate from the “normal API.”


Note on fyne and crush#

Neither fyne nor crush has a plugin system in the traditional sense.

Fyne — extensibility is at the renderer/backend level, selected at compile time via build tags (OpenGL, WASM, software). Theme customization via fyne.Theme interface (Caddy-style interface injection) is the primary extension point. No runtime plugin loading.

Crush — a TUI app with no extension points. See analysis/results/P51-crush--ai-development-profile.md for context on its development approach. Its interface-driven design (bubbletea conventions) is inherent to the TUI domain rather than a plugin architecture signal.