Write a plugin
Package a feature so any Anetos app can add it with anetos add: its
routes, tables, settings, commands, queue jobs, scheduled tasks and
event listeners. A plugin uses only the framework’s public packages, as
an app does. The complete plugin is
plugins/postmark, which receives
Postmark’s webhooks and keeps a list of the addresses Postmark stopped
sending to.
Before you start
You know how to use plugins. A plugin is a Go module with a package that exports a constructor:
// illustrative
func Plugin() ext.Pluginanetos add lists yourpkg.Plugin() in the app’s plugins.go, so the
function must have that name and signature, in the module’s root
package (or the package path users add).
Steps
1. Name it and declare what it supports
ext.Plugin has one method, Name. Everything else is optional
interfaces for what the plugin adds. The name namespaces all of it:
lower-case letters, digits and -, starting with a letter, up to 40
characters.
// Settings are the plugin's settings.
type Settings struct {
// WebhookUser is the user name of the basic auth Postmark sends with
// webhooks (set in the webhook's URL). Without it and the password,
// the webhook endpoint refuses every request.
WebhookUser string `env:"POSTMARK_WEBHOOK_USER"`
// WebhookPassword is the webhook's password.
WebhookPassword anetos.Secret `env:"POSTMARK_WEBHOOK_PASSWORD"`
}
type plugin struct{ cfg Settings }
func (p *plugin) Name() string { return "postmark" }
func (p *plugin) Requires() string { return ">= v0.2.0, < v0.3.0" }
func (p *plugin) Config() any { return &p.cfg }(Copied from plugins/postmark/plugin.go, region plugin.)
Requires(ext.Compat) lists the Anetos versions the plugin works with: comparisons (>=,>,<=,<,=) ofvX.Y.Zversions, joined by commas. Before v1, minor versions may break APIs, so allow one minor series. A pre-release (v0.2.0-rc.1) counts as its release, and an app built from an untagged commit or a local checkout of Anetos has the version that source is heading for (v0.2.0-dev).Config(ext.HasConfig) returns a pointer to the settings struct, withenvtags as forconfig.Get: defaults,required, typed fields,anetos.Secretfor secrets. Every key must start with the name in capitals (POSTMARK_).ext.Loadfills it before calling the other methods, so they can use it.
2. Add tables
ext.HasMigrations returns a migration set named after the plugin. Its
migrations run with the app’s (migrate), and are tracked under that
name:
// Migrations implements ext.HasMigrations.
func (p *plugin) Migrations() *migrate.Set {
s := migrate.NewSet("postmark")
s.AddFunc("2026_10_02_000000_create_postmark_suppressions",
func(s *migrate.Schema) error {
return s.Create("postmark_suppressions", func(t *migrate.Table) {
t.ID()
t.String("email", 254)
t.String("record_type", 40)
t.String("reason", 255)
t.String("message_id", 64)
t.Timestamps()
t.Unique("email")
})
},
func(s *migrate.Schema) error { return s.Drop("postmark_suppressions") })
return s
}(Region migrations.)
Prefix your tables with the plugin’s name, so they don’t collide with the app’s. Never change a released migration: add a new one.
3. Add routes
ext.HasRoutes gets a router under the plugin’s prefix, /<name> by
default (the app can move it with ext.Mount), whose route names start
with <name>.. Use relative paths and route names, never the full path:
// Routes implements ext.HasRoutes: POST /webhook under the plugin's
// prefix.
func (p *plugin) Routes(r *web.Router) error {
r.Post("/webhook", p.webhook).Name("webhook")
return nil
}
// webhook checks the credentials and queues the event: Postmark retries
// a webhook that doesn't get a 2xx quickly.
func (p *plugin) webhook(c *web.Ctx) error {
if !p.authorized(c.Request()) {
c.Writer().Header().Set("WWW-Authenticate", `Basic realm="postmark"`)
return web.Error(http.StatusUnauthorized, "unauthorized")
}
var e Event
body := http.MaxBytesReader(c.Writer(), c.Request().Body, 1<<20) // events are a few KB
if err := json.NewDecoder(body).Decode(&e); err != nil {
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
return web.Error(http.StatusRequestEntityTooLarge, "too large")
}
return web.Error(http.StatusBadRequest, "invalid JSON")
}
switch e.RecordType {
case "Bounce", "SpamComplaint", "SubscriptionChange":
if a := e.address(); a != "" && len(a) <= 254 {
if err := queue.DispatchFunc(c, "postmark:webhook", e); err != nil {
return err
}
}
}
return c.NoContent() // other events, and events without an address: acknowledged, ignored
}(Region routes.)
The routes get the app’s global middleware, and middleware the app adds
to its root router with Use (CSRF there would refuse a webhook: apps
put it on a group). The router is the app’s, so UseGlobal would wrap
every request of the app: don’t. For pages, render templ components from
the plugin’s package: they are Go code, compiled with it.
4. Add jobs, tasks, listeners and commands
Each capability gets the app’s service to add to:
| Interface | Method | Name what it adds |
|---|---|---|
ext.HasJobs | Jobs(q *queue.Queue) error: queue.Register, queue.RegisterFunc; q.Work for workers of its own | <name>:… |
ext.HasSchedule | Schedule(s *schedule.Scheduler) error: s.Add | <name>:… |
ext.HasListeners | Listen(bus *events.Bus) error: events.On, OnAsync, OnQueued | |
ext.HasCommands | Commands() []cmd.Command | <name>:… (checked) |
ext.HasBoot | Boot(ctx, app) error: runs when the app boots, after the app’s own providers |
// Jobs implements ext.HasJobs: the job postmark:webhook, run by the
// app's workers.
func (p *plugin) Jobs(q *queue.Queue) error {
return queue.RegisterFunc(q, "postmark:webhook", record)
}(Region jobs.)
Jobs run on the app’s workers, and use the app’s database, cache and mailer through their context, like the app’s own jobs.
5. Test it
Test the plugin in a small app: a setup that sets up what the plugin
needs, then loads it, run by anetostest:
func setup(app *anetos.App) (*web.Server, error) {
if _, err := db.Connect(context.Background(), app, sqlite.Driver()); err != nil {
return nil, err
}
if _, err := migrate.ForApp(app, nil); err != nil {
return nil, err
}
if _, err := queue.ForApp(app); err != nil {
return nil, err
}
srv, err := web.NewServer(app)
if err != nil {
return nil, err
}
return srv, ext.Load(app, []ext.Plugin{postmark.Plugin()})
}(Copied from plugins/postmark/plugin_test.go, region test-setup.)
anetostest.New(t, setup, anetostest.Env(…)) runs the plugin’s
migrations on an in-memory database; requests go to /postmark/….
Then try it in a real app: go mod edit -replace your module to its
directory, and anetos add it with any version.
6. Publish it
Tag the module (v0.1.0) and push it: anetos add example.com/you/yourplugin gets it through the Go module proxy. Say in
its README what it adds (plugins:list shows it too), its settings,
and the Anetos versions it supports.
Rules
ext.Load refuses a plugin that breaks them, naming the rule:
- The name is valid, isn’t one the framework uses (
app,auth,cache,db,events,ext,health,help,http,log,mail,mailer,migrate,plugins,pubsub,queue,redis,routes,run,schedule,anetos,serve,session,social,storage,web), and no other plugin has it. Requiresis satisfied by the app’s Anetos version (anetos.Version()).Configreturns a pointer to a struct, whose settings start with<NAME>_(-becomes_), in upper-case letters, digits and_, and aren’t in another plugin’s namespace (stripecan’t haveSTRIPE_CONNECT_KEYnext to astripe-connectplugin).- The migration set is named after the plugin.
- Commands are named
<name>:…. - The services the plugin adds to are set up before
ext.Load(call queue.ForApp before ext.Load). ext.Mountnames a loaded plugin with routes, and a clean path like/billing/stripe(no trailing/,.or..segments, or wildcards).
A plugin whose settings are missing or invalid isn’t wired in, and the
app doesn’t boot, with an error naming them; commands that don’t boot
the app (plugins:env, help) still work.
Next steps
- Use plugins:
anetos add,ext.Load,ext.Mount. - Migrations, Queues, Scheduling, Events, Commands: what plugins add.
Coming from Laravel? A plugin is a package with a service provider:
Nameand theHas…interfaces replaceregister()andboot(),ConfigreplacesmergeConfigFrom,MigrationsreplacesloadMigrationsFrom, andCommandsreplacescommands(). There is no auto-discovery incomposer.json:anetos addwrites the app’splugins.go, and the app callsext.Load.