Plugins (Shuttle) — How They Plug In
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:
- HTTP routes outside
/api. Webhooks, OAuth callbacks, custom dashboards, anything that doesn't fit the auto-generated CRUD shape. - 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