Expert May 3, 2026 · 3 min read

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 loom itself.
  • 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?