Intermediate May 3, 2026 · 2 min read

A Shuttle is a Loom plugin: a Go value that adds HTTP routes and lifecycle hooks to the runtime at boot. The metaphor matches the framework's name — Loom weaves Threads, and a shuttle is the tool that carries new yarn across the loom without changing the loom itself.

What plugins can do

Two extension points in v0.1.0:

  1. HTTP routes outside /api. Webhooks, OAuth callbacks, custom dashboards, anything that doesn't fit the auto-generated CRUD shape.
  2. Lifecycle hooks on Thread CRUD operations: before_save, after_save, before_delete, after_delete. Hook functions receive the record being written and can mutate it (Before*) or trigger side effects (After*).

Future versions may add custom field types, custom validators, and CLI commands — but for now, routes and hooks are everything.

How plugins load

There's no dynamic plugin loading (the Go plugin package is platform-restricted and effectively deprecated). Plugins are Go imports: you write a small main.go that pulls in the plugins you want and calls runtime.NewServer with them:

import (
    "github.com/orbweaver-dev/loom/internal/runtime"
    "github.com/orbweaver-dev/loom/pkg/shuttle"
    "github.com/orbweaver-dev/loom/pkg/shuttle/stripe"
)

func main() {
    plugins := []shuttle.Shuttle{
        stripe.New(handleStripeEvent),
        myCustomShuttle{},
    }
    srv, _ := runtime.NewServer(runtime.ServerOptions{
        DB:      db,
        Threads: threads,
        Auth:    auth,
        Plugins: plugins,
    })
    srv.Start()
}

The trade-off vs dynamic loading: you have to compile your project, but you get type safety, no version skew, and zero runtime surprises.

A minimal plugin

type Audit struct{}

func (Audit) Name() string    { return "audit" }
func (Audit) Version() string { return "0.1.0" }

func (Audit) Init(r *shuttle.Registry) error {
    return r.AddHook("Customer", shuttle.AfterSave,
        func(ctx context.Context, rec map[string]any) error {
            log.Printf("audit: customer %v saved", rec["id"])
            return nil
        })
}

Three methods. The Init runs once at boot; from there it's all event-driven.

Hook semantics

Event Mutations to record persist? Error effect
before_save Yes — written to the database HTTP 400, write does not happen
after_save No — record is already persisted Logged at warn level; HTTP response unaffected
before_delete N/A (only id is in the record) HTTP 400, delete does not happen
after_delete N/A Logged at warn level

Hook chains run in registration order, synchronously. Hook A can mutate the record, hook B sees the mutation, hook C sees both. Concurrency within a hook is the plugin's responsibility.

See also

  • Reference → Shuttle Plugin Interface — exact types and method contracts
  • Recipes → Write a Custom Shuttle Plugin — step-by-step
  • Tutorials → Adding a Stripe Webhook with Shuttle — full example
Was this article helpful?