Intermediate May 3, 2026 · 2 min read

Loom can run your application two different ways. Pick the mode based on how much custom Go you need to write — you can switch any time.

Interpreter mode (the default)

loom serve reads your Thread YAML at startup and serves CRUD routes using a generic handler. There's no compile step for your application code — Loom is the binary that runs, and your project is just YAML + config.

threads/customer.yaml ──┐
threads/invoice.yaml  ──┼─→ loom serve ──→ HTTP server with /api/customers, /api/invoices
loom.yaml             ──┘

This is closest to how Frappe works. You change a Thread, restart loom serve, and the new fields are live. No go build, no deploy artefact rebuild.

Ejected mode

After loom eject Customer, the four Go files for the Customer model move from .loom/go/... to custom/... and have their "Generated by Loom" header stripped. From that point you write your own main.go, import the framework's runtime, and wire the ejected handlers through chi:

import (
    "github.com/orbweaver-dev/loom/internal/runtime"
    "your-module/custom/handlers"
    "your-module/custom/routes"
)

func main() {
    db, _ := runtime.Open(runtime.DBOptions{})
    r := chi.NewRouter()
    routes.MountCustomerRoutes(r, db)
    http.ListenAndServe(":8080", r)
}

You're now responsible for keeping the ejected code current with the Thread definition; Loom won't overwrite it (that's the point).

When to eject

Eject when interpreter mode can't express what you need. Common reasons:

  • Custom validation logic that's too specific for the field-type system
  • Complex business rules in before_save / after_save (a small hook is fine in interpreter mode; a 200-line transaction script wants ejection)
  • Cross-thread queries that the generic handler doesn't generate
  • Streaming endpoints or non-CRUD routes that don't fit the /api/{table} shape

If you're not sure, don't eject. Interpreter mode is faster to iterate on, and you can always eject later.

What you give up by ejecting

  • Auto-regeneration when fields change. Add a field to the Thread? In interpreter mode, restart and you're done. After ejection, you have to update the Go struct manually (or use loom weave then diff against your custom code).
  • One source of truth. Now there's the Thread YAML and the ejected Go. When they disagree, behaviour follows the Go.

The middle path

Many real projects use both: most Threads run via interpreter mode (the boring CRUD), and one or two Threads with complex business logic are ejected. The runtime handles this fine — eject what you need to customise, leave the rest.

See also

  • Recipes → Eject a Model and Customise Handlers — step-by-step
  • Concepts → Lifecycle Hooks — for changes that don't need ejection
Was this article helpful?