Create your own extension
A PocketOne extension is Go code compiled into your build. It can add, to every instance:
- Go routes;
- PocketBase hooks;
- collections;
- admin dashboard screens.
It can also run process-wide jobs and middleware.
The extension contract: extkit
Write an extension once, against extkit (its own module, github.com/pockethost/pocketone/ext/extkit; core has no part in it), and the same value runs on a single app
and on every instance under pocker:
type Extension struct{ cfg Config }
func New(cfg Config) *Extension { return &Extension{cfg: cfg} }
func (p *Extension) Name() string { return "hello" } // config key, `_hello_` collections, logs
func (p *Extension) Register(app core.App, cfg extkit.Config) error {
c := p.cfg
if err := cfg.Decode(&c); err != nil { // this app's settings over the defaults (json tags)
return err
}
app.OnServe().BindFunc(func(se *core.ServeEvent) error { /* routes, UI extensions */ return se.Next() })
return nil
}
// optional: a $hello global in the app's JS hooks
func (p *Extension) BindJS(app core.App, vm *goja.Runtime) { /* vm.Set("$hello", ...) */ }
- Single app:
extkit.MustRegister(app, extensions...), andextkit.BindJS(app, vm, extensions...)in jsvm'sOnInit. The standard binary does this for the extensions it ships (examples/pocketone). - Pocker: put the extension in
Server.Extensions. Pocker callsRegisterfor every instance with that instance's settings (itsextensions.<name>), over the defaults given to your constructor; process-wide settings come from a pocketone.yaml section your extension owns (extkit.NewSection).enabled: falsemeansRegisterandBindJSare never called for that instance. - No pocker import: a contract extension doesn't depend on ext/multisite-provider-pocker, so single-app builds don't link it. Reach for the host interfaces below only for what's process-wide (a request gate, TLS, background jobs).
- Name the command with
extkit.Command: when a message tells people what to run ("run 'pocketone login prod'"), build it fromextkit.Command(defaultpocketone), never a literal. A build shipped under another name (PocketHost's, say) sets it once inmain, or with-ldflags "-X github.com/pockethost/pocketone/ext/extkit.Command=pockethost". - bulkmail is the reference implementation.
Status.
extkitcovers per-app extensions. The process-wide host interfaces below (pocker.Serverfields andServer.Extensionshost interfaces) are pocker's for now; apocketbase.Main(extensions...)entry point is planned.
A complete, runnable example is in ext/multisite-provider-pocker/example.
Compiled in, not loaded at run time
Extensions are Go packages linked into the binary, the way Caddy modules work. Go's extension package (.so files)
isn't used:
- it needs identical toolchain and dependency versions;
- it can't unload;
- it rules out static
CGO_ENABLED=0builds.
A build is a small main.go that starts a pocker.Server with your code.
Your module
PocketOne keeps PocketBase's module path, so upstream merges stay clean. Point it at PocketOne in your go.mod:
require ganigeorgiev.com/pocketbase/pocketbase v0.41.1
replace ganigeorgiev.com/pocketbase/pocketbase => github.com/pockethost/pocketone <commit or version>
Go only honors replace in the main module, so the final binary's go.mod needs this line, not only your
extension's.
Per instance: Server.SetupApp
SetupApp runs for every instance's app after its migrations and before it serves. Bind hooks and routes there, as
you would in a stand-alone PocketBase main.go:
srv := &pocker.Server{
Config: cfg,
SetupApp: func(inst pocker.Instance, app core.App) error {
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/hello", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"instance": inst.ID})
}).Bind(apis.RequireSuperuserAuth())
return se.Next()
})
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordEvent) error {
// ...
return e.Next()
})
return nil
},
}
instis the instance's config from the instances file or your provider: id, hosts andinst.Private.- Private values are for your Go code only and are never visible to the instance's JS. Use them for per-instance tokens your extension needs.
- If your extension needs its own collections, create them in a migration or on bootstrap. Prefix them with
_<extension>_so they never collide with the app's collections, and make them superuser-only unless they must be public.
Admin screens: UIExtensions
The dashboard loads extensions before it starts its router:
- Each extension is a named file system served at
/_/extensions/<name>/. - Every extension's
main.jsis bundled into/_/extensions.js.
Add yours in the same OnServe hook:
//go:embed ui
var uiFiles embed.FS
ui, _ := fs.Sub(uiFiles, "ui")
se.UIExtensions = append(se.UIExtensions, core.UIExtension{Name: "hello", FS: ui})
ui/main.js runs in the dashboard with its UI kit (t.*) and globals (app.routes, app.store, app.pb):
// a new page, with a link in the header
app.routes.superuserOnly("#/hello", async () => {
const res = await app.pb.send("/api/hello");
return t.div({ className: "page" },
t.div({ className: "page-content" }, t.h1({}, "Hello"), t.p({}, `Instance ${res.instance}`)),
);
});
app.store.headerLinks.push({ href: "#/hello", icon: "ri-emotion-happy-line", label: "Hello" });
// add to an existing screen: screens marked with pbEvent fire mount:<name> with their element
document.addEventListener("mount:pageBackupsSettings", (e) => {
e.detail.prepend(t.p({ className: "txt-hint" }, "Hello from an extension."));
});
- More hooks:
app.store.settingsNavGroupsadds entries to the Settings sidebar. SearchpbEvent:inui/srcfor every screen you can add to (Backups exposespageBackupsSettings,backupsList,backupsSettingsForm,backupCreateModalandbackupRestoreModal). - No build step: plain JS works. Bundle with Vite if you prefer.
- Experimental upstream: this UI extension API is undocumented and can change between PocketBase releases
(
ui/README.md). Keepmain.jssmall, and test it after each upstream update.
Process-wide: middleware, jobs, events
| Field | Use it for |
|---|---|
Server.Extensions |
Extensions: any extkit.Extension (registered on every instance), plus host-only extras (Configurer, Gate, TLSProvider, Wrapper, Starter; see POCKER.md), e.g. pitr, storage, mailrelay, autotls, servertiming. |
Server.Wrap |
Middleware around every instance request, e.g. auth in front of all instances, metrics, headers. |
Server.OnStart |
Gets the Manager before the first request: start background jobs (schedules, sweeps) in your own goroutines. |
Server.OnEvent |
Instance events: started, stopped, restarted, failed. |
Server.Source |
Your own InstanceSource: instances from a database or control plane instead of instances.yaml. |
Server.Forward |
How requests for instances running on another server are forwarded. |
From the Manager, host code can:
- change an instance's files safely:
WithInstanceStopped(ctx, id, fn)stops it, holds its requests, runsfn, then starts a fresh app; - start it:
StartInstance; - find its folder:
InstanceDir; - write to its log:
LogInstance.
Rules for extensions that host untrusted instances
Your Go code runs with the host's full power, outside the JS sandbox:
- Act only for the instance you were called for. Use
inst.IDandManager.InstanceDir(inst.ID); never build a path from request input. - Instance data needs instance auth. Protect routes that read or change it with PocketBase's auth middlewares
(
apis.RequireSuperuserAuth(), record auth). - Exposing something to JS is a sandbox decision. Expose a function to an instance's JS only if it's safe for a hostile tenant to call. See How is this safe?.
- Outbound calls follow the egress policy. Go's default HTTP client already refuses private addresses. If your
extension must reach a private service, give it its own
http.Clientand transport, and never let instance input pick the address.
Contributing an extension
Extensions are welcome, from us and from you. Open a pull request that adds ext/<your-extension>/.
Change PocketBase's core as little as possible
PocketOne stays close to upstream so every PocketBase release merges cleanly. If your extension needs something the core doesn't offer yet:
- Add the smallest hook that makes it possible: an extension point or an override, not your extension's logic. A nil-checked function field, a list callers can append to, or an event, with upstream behavior unchanged when nothing is registered.
- Make it generic. Name it for what it allows ("storage backend for an app"), not for your extension. Assume someone else's extension will use it too, and that we'll offer it to upstream PocketBase.
- Mark every changed line in PocketBase's own files with
// PocketOne:and a few words on why, so the next upstream merge knows what to keep. - Keep it separate: the core change goes in its own commit, with a test, ahead of the extension.
- Prefer what exists: jsvm
OnInit,OnServe, the record and request hooks,BaseAppConfig.DBConnect,UIExtensions. Many extensions need no core change at all.
What an extension folder contains
ext/<name>/
go.mod module github.com/pockethost/pocketone/ext/<name>
README.md the extension's docs, and its page on pocketone.dev (below)
<name>.go the extension
<name>_test.go
conformance_test.go the shared extension test (below)
example/main.go a minimal program that builds cleanly and shows the feature
ui/ optional: admin screens (embedded main.js)
-
example/main.gois required. Keep it as small as possible: just enough to build a binary that shows the feature working, with no extra harness.go build ./exampleandgo vet ./...must pass with no warnings.ext/multisite-provider-pocker/exampleandext/sandbox/exampleare the size to aim for. -
Examples come with two logins: superuser
[email protected]and user[email protected](verified, inusers), both with passwordpassword. Everyexample/main.gocallsdevusers.Register(app)right afterpocketbase.New()(under pocker,devusers.Ensure(app)inSetupApp), fromext/devusers. A fresh data dir is then ready to log in to, with no installer page and no CLI step. Never register it in a production build. -
conformance_test.gois required. It runs the shared extension test fromext/extkit/conformanceonce:func TestConformance(t *testing.T) { conformance.Run(t, func() extkit.Extension { return myextension.New(myextension.Config{}) }) }It checks that the extension registers, migrates, serves and shuts down on a fresh app with no goroutines left behind; that PocketBase's own collections are unchanged and every collection it adds is named
_<name>or_<name>_…; and that one extension value serves several apps at once (as under pocker) without registering on one app touching another's hooks. An extension that adds MCP tools (ext/mcp) also passesconformance.Options{Setup: ...}that registers MCP on the test apps; the test then checks each tool is named<name>_<verb>_<noun>, is described, and has an object input schema (pb_is reserved). Run it withgo test -race. It's the minimum every extension passes; your own tests cover the feature. -
The README has five required sections, in this order. It is also your extension's page on pocketone.dev.
## Install: how to get it (built intopocketone, or the lines to add to a Go build).## Configure: thepocketone.yamlsection, flags and settings screens. The site adds a table of your section's keys, generated from yourextkit.NewSectiondefaults; describe each key with adoc:"…"struct tag.## Use: day to day: the admin screens and what to click, the commands.## Integrations: the extensions yours works with and how (pocker and multi-instance hosting go here, not in the sections above), and how other extensions can build on yours (hooks, filesystem kinds, per-instance settings, Go and REST APIs).## Example: the code that uses it. A sentence on what yourexample/main.goruns, then the whole file in a```goblock, exactly as it is;go run ./.github/extensions examplescopies it in, and CI fails when the two differ. Without an example (an extension built intopocketone), say where to see it used.
Write the first three for someone running one PocketOne app, not for a Go developer. Anything else (how it works, limits) goes in sections after these, and extra guides can sit next to the README as other
.mdfiles. -
Extensions here are MIT. The README ends with
## Licenseand the single lineMIT. An extension under another license lives in its author's own repository, not here. Add your extension tosite/extensions.jsonand runcd site/sync && go run .to build its page.ext/pitr/README.mdis the example to follow. -
CI enforces all of this.
.github/workflows/extensions.ymlfails when an extension README is missing one of the five sections or its MIT## License, or its Example doesn't matchexample/main.go, or when a module that implementsextkit.Extensionhas no conformance test, then builds, vets and runsgo test -raceon every module the change affects: the modules it touches, every module that reaches one of them through areplace, or all of them for a core change. Run the same checks locally from the repository root:go run ./.github/extensions check go run ./.github/extensions affected origin/main HEADTests that need an external tool (MinIO, Docker) skip themselves when it isn't installed, so they don't block CI.
-
Dependencies stay in your module. Heavy libraries go in your extension's
go.mod, never in core's. -
Single app first. Where it makes sense, an extension should work on one app, and add more under the pocker (multitenant) host.
-
MIT, like PocketBase and the rest of this repo.
Planned extensions
Our own extensions live in this repo under ext/, each as its own Go module (github.com/pockethost/pocketone/ext/<name>). A multisite- name means the extension only works on a multi-instance host. See ext/README.md.
Questions and comments
Comments are a GitHub Discussions thread; sign in with GitHub to post.