Expert May 3, 2026 · 3 min read

Exact types and contracts for the Shuttle plugin system, from pkg/shuttle/.

Interface

type Shuttle interface {
    Name() string                 // stable identifier; logs and conflict detection
    Version() string              // informational; semver
    Init(r *Registry) error       // called once at boot
}

Init runs before any HTTP traffic. Returning a non-nil error aborts server boot — use this for missing required env vars or bad config.

Registry

type Registry struct { /* opaque */ }

func NewRegistry() *Registry
func (r *Registry) AddRoute(method, pattern string, h http.Handler) error
func (r *Registry) AddHook(model string, event HookEvent, fn HookFunc) error
func (r *Registry) Routes() []Route
func (r *Registry) Hooks() []Hook
func (r *Registry) HooksFor(model string, event HookEvent) []Hook

Append-only collector. Plugins call AddRoute / AddHook from inside Init; the runtime reads the Routes and Hooks slices afterwards.

AddRoute validates the obvious shape errors at registration: empty method, relative pattern, nil handler.

AddHook validates: empty model, unknown event, nil function.

Route

type Route struct {
    Method  string         // GET, POST, PUT, DELETE, …
    Pattern string         // chi syntax, absolute (/webhooks/stripe)
    Handler http.Handler   // any standard http.Handler
    Source  string         // populated by Load — the plugin's Name()
}

Plugin routes mount on the root router, outside /api. They do not pass through the JWT auth chain — webhooks and OAuth callbacks belong here. If a plugin route needs user auth, the plugin wraps its own middleware.

Hooks

type HookEvent string

const (
    BeforeSave   HookEvent = "before_save"
    AfterSave    HookEvent = "after_save"
    BeforeDelete HookEvent = "before_delete"
    AfterDelete  HookEvent = "after_delete"
)

type HookFunc func(ctx context.Context, record map[string]any) error

type Hook struct {
    Model  string
    Event  HookEvent
    Fn     HookFunc
    Source string
}

Hook chains run synchronously, in registration order. Mutations to record by Before* hooks are persisted; mutations by After* hooks are no-ops.

Errors from Before* hooks return HTTP 400 with the error message in the body. Errors from After* hooks log at warn level (plugin=<name> event=<event> err=<err>) but the HTTP response is unaffected.

Loading

func Load(shuttles []Shuttle) (*Registry, error)

The runtime calls this once at boot. Behaviour:

  • Iterates plugins in input order
  • Calls each plugin's Init with a single shared Registry
  • Stamps every registered Route and Hook's Source field with the plugin's Name()
  • Refuses duplicate plugin names (programming error)
  • Refuses empty plugin names

Load returns the first error encountered, with the responsible plugin's name in the error message. The runtime aborts boot on any error — broken plugin = no startup, not a half-running process.

Wiring into the server

runtime.NewServer(runtime.ServerOptions{
    DB:      db,
    Threads: threads,
    Auth:    auth,
    Plugins: []shuttle.Shuttle{
        myPlugin,
        anotherPlugin,
    },
})

NewServer calls shuttle.Load and returns its error if any. Hooks become available to ResourceHandler automatically — no extra wiring.

What plugins can NOT do (in v0.1.0)

  • Add custom field types — would require parser hooks
  • Add custom CLI commands — would require Cobra integration on the loom binary itself
  • Replace built-in handlers — only additive routes; no overriding /api/...
  • Run dynamically — Go imports only, no .so plugins

These are conscious omissions. The current surface keeps the runtime predictable and type-safe.

See also

  • Concepts → Plugins (Shuttle) — overview and metaphor
  • Tutorials → Adding a Stripe Webhook with Shuttle — full example
Was this article helpful?