How to Write a Custom Shuttle Plugin
Build a small Shuttle plugin from scratch. The example: an audit-log plugin that writes a JSON line to stderr for every Customer save and delete.
The full plugin
// custom/auditlog/auditlog.go
package auditlog
import (
"context"
"encoding/json"
"log/slog"
"os"
"time"
"github.com/orbweaver-dev/loom/pkg/shuttle"
)
// Plugin is the Shuttle. Construct with New so the in-memory writer
// channel is initialised; field-literal Plugin{} would race.
type Plugin struct {
Models []string // Threads to audit (defaults to all when empty)
}
func New(models ...string) *Plugin { return &Plugin{Models: models} }
func (Plugin) Name() string { return "auditlog" }
func (Plugin) Version() string { return "0.1.0" }
func (p *Plugin) Init(r *shuttle.Registry) error {
targets := p.Models
if len(targets) == 0 {
// Empty = audit every Thread. Loom doesn't surface the
// Thread list to plugins yet, so the caller passes them.
slog.Warn("auditlog: no models configured; plugin is a no-op")
return nil
}
for _, model := range targets {
for _, ev := range []shuttle.HookEvent{
shuttle.AfterSave, shuttle.AfterDelete,
} {
ev := ev // capture loop var
if err := r.AddHook(model, ev, p.makeHook(model, ev)); err != nil {
return err
}
}
}
return nil
}
func (p *Plugin) makeHook(model string, ev shuttle.HookEvent) shuttle.HookFunc {
return func(_ context.Context, rec map[string]any) error {
line, _ := json.Marshal(map[string]any{
"ts": time.Now().UTC().Format(time.RFC3339),
"model": model,
"event": string(ev),
"id": rec["id"],
})
_, _ = os.Stderr.Write(append(line, '
'))
return nil
}
}
Wire it in
import "you/custom/auditlog"
plugins := []shuttle.Shuttle{
auditlog.New("Customer", "Invoice"),
// ...others
}
runtime.NewServer(runtime.ServerOptions{Plugins: plugins, /* ... */})
At boot, Loom logs:
INFO mounted plugin route — well, no routes; this plugin only adds hooks
…and on the first save:
{"ts":"2026-05-03T18:30:01Z","model":"Customer","event":"after_save","id":"5e2b...."}
Anatomy
| Concern | Where it's handled |
|---|---|
| Plugin name (must be unique) | Name() |
| Version (informational) | Version() |
| Boot-time setup | Init — runs once before any HTTP traffic |
| Per-event work | HookFunc returned from makeHook |
| Failure semantics | Returning error from Init aborts boot; from after-hook logs at warn |
Adding routes
To add an HTTP route instead of (or in addition to) a hook:
func (p *Plugin) Init(r *shuttle.Registry) error {
return r.AddRoute("GET", "/audit/health", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true,"plugin":"auditlog"}`))
}))
}
Routes mount on the root router, outside /api. They don't pass through JWT auth — webhook-style endpoints belong here.
Testing
Plugin tests are vanilla httptest. From internal/runtime/plugins_test.go:
plugin := fakePlugin{
name: "ping",
init: func(r *shuttle.Registry) error {
return r.AddRoute("GET", "/ping", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
_, _ = w.Write([]byte("pong"))
}))
},
}
srv, _ := runtime.NewServer(runtime.ServerOptions{
DB: stubDB(t), Plugins: []shuttle.Shuttle{plugin}, ...
})
req := httptest.NewRequest("GET", "/ping", nil)
rec := httptest.NewRecorder()
srv.Router().ServeHTTP(rec, req)
// assert rec.Code == 200, body == "pong"
What you can't do (yet)
- Add CLI commands. Plugins can't extend
loomitself. - Add custom field types. The validator's known types are fixed.
- Override
/api/...routes. Plugin routes are additive only. - Plug into
loom check/loom weave. Plugins are runtime-only.
These are conscious omissions for v0.1.0. The current surface keeps the runtime predictable.
See also
- Reference → Shuttle Plugin Interface — exact types and method signatures
- Tutorials → Adding a Stripe Webhook with Shuttle — a realistic end-to-end plugin
Was this article helpful?