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...), and extkit.BindJS(app, vm, extensions...) in jsvm's OnInit. The standard binary does this for the extensions it ships (examples/pocketone).
  • Pocker: put the extension in Server.Extensions. Pocker calls Register for every instance with that instance's settings (its extensions.<name>), over the defaults given to your constructor; process-wide settings come from a pocketone.yaml section your extension owns (extkit.NewSection). enabled: false means Register and BindJS are 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 from extkit.Command (default pocketone), never a literal. A build shipped under another name (PocketHost's, say) sets it once in main, or with -ldflags "-X github.com/pockethost/pocketone/ext/extkit.Command=pockethost".
  • bulkmail is the reference implementation.

Status. extkit covers per-app extensions. The process-wide host interfaces below (pocker.Server fields and Server.Extensions host interfaces) are pocker's for now; a pocketbase.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=0 builds.

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
    },
}
  • inst is the instance's config from the instances file or your provider: id, hosts and inst.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.js is 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.settingsNavGroups adds entries to the Settings sidebar. Search pbEvent: in ui/src for every screen you can add to (Backups exposes pageBackupsSettings, backupsList, backupsSettingsForm, backupCreateModal and backupRestoreModal).
  • 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). Keep main.js small, 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, runs fn, 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.ID and Manager.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.Client and 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.go is required. Keep it as small as possible: just enough to build a binary that shows the feature working, with no extra harness. go build ./example and go vet ./... must pass with no warnings. ext/multisite-provider-pocker/example and ext/sandbox/example are the size to aim for.

  • Examples come with two logins: superuser [email protected] and user [email protected] (verified, in users), both with password password. Every example/main.go calls devusers.Register(app) right after pocketbase.New() (under pocker, devusers.Ensure(app) in SetupApp), from ext/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.go is required. It runs the shared extension test from ext/extkit/conformance once:

    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 passes conformance.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 with go 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 into pocketone, or the lines to add to a Go build).
    • ## Configure: the pocketone.yaml section, flags and settings screens. The site adds a table of your section's keys, generated from your extkit.NewSection defaults; describe each key with a doc:"…" 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 your example/main.go runs, then the whole file in a ```go block, exactly as it is; go run ./.github/extensions examples copies it in, and CI fails when the two differ. Without an example (an extension built into pocketone), 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 .md files.

  • Extensions here are MIT. The README ends with ## License and the single line MIT. An extension under another license lives in its author's own repository, not here. Add your extension to site/extensions.json and run cd site/sync && go run . to build its page. ext/pitr/README.md is the example to follow.

  • CI enforces all of this. .github/workflows/extensions.yml fails when an extension README is missing one of the five sections or its MIT ## License, or its Example doesn't match example/main.go, or when a module that implements extkit.Extension has no conformance test, then builds, vets and runs go test -race on every module the change affects: the modules it touches, every module that reaches one of them through a replace, 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 HEAD
    

    Tests 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.