developing
rig is a module before it is a binary.
The binary at cmd/rig is one wiring of github.com/mrsirg97-rgb/rig/v3. Import the module and the loop, the seams, the stores and the event engine are yours — with the same ordering and failure semantics the binary has, because it is the same code.
This page is the embedder's contract: the five seams, the nine kernel options, one working example, and the freeze that keeps the surface honest.
get it
One import, no runtime
go get github.com/mrsirg97-rgb/rig/v3@latest // the module import ( "github.com/mrsirg97-rgb/rig/v3" // the kernel: rig.New "github.com/mrsirg97-rgb/rig/v3/core" // the seams and the wire types "github.com/mrsirg97-rgb/rig/v3/loop" // loop.Run(ctx, k) "github.com/mrsirg97-rgb/rig/v3/store" // SQLite stores, migrations )
The core is standard library only. The one store dependency is pure-Go SQLite, so an embedding binary cross-builds with CGO_ENABLED=0. The event engine is its own module — github.com/mrsirg97-rgb/evt — and it is the engine, not a directory in this tree.
the seams
Five interfaces hold the shape
A seam is one method (or one small family) with a name that says what crosses it. The loop names none of the concrete types; every dependency is resolved at the composition root.
| seam | shape | role | swapped with |
|---|---|---|---|
| Provider | Stream(ctx, Request) (<-chan Event, error) | model access, streaming | WithProvider |
| Tool | Name / Description / Schema / Exec(ctx, args) | a capability | WithTools |
| Frontend | Input(ctx) (string, error); Notify(Event) | human I/O and the event sink | WithFrontend |
| ContextPolicy | Assemble(ctx, session) ([]Message, error) | context construction, per turn | WithPolicy |
| ToolMiddleware | Wrap(ToolExec) ToolExec | cross-cutting tool policy | WithMiddleware |
Two capabilities ride the middleware by assertion, not obligation: TurnStart lets a link observe the turn boundary (the guard clears its counts there), and Guidelines lets it contribute system-prompt prose. One mechanism, not a second.
the kernel
Nine options, wired once
rig.New(opts ...Option) is the composition root as a struct. Duplicate tool or command names panic at New, not at the first call. The binary wires seven of the nine; WithCommands rides the frontend dispatchers and WithParallel stays at its default.
| option | what it is | the binary's wiring |
|---|---|---|
| WithProvider | one turn, streamed | openai.New(...) / anthropic.New(...) on the row's wire |
| WithTools | the tool table | the tool/* adapters plus the plugin door |
| WithFrontend | input pull, event notify | tui, cli or oneshot |
| WithPolicy | message assembly | compact.New, the per-model trigger |
| WithCommands | the slash-command set | unwired at the root: commands dispatch at the frontend seam |
| WithMiddleware | the tool-exec chain | toolset, approve, cutoff, perm, guard, paths; the graph tap inside, the decision links outside |
| WithPriority | a priority per tool call | the registry's predicate: a write 60, a read 50 |
| WithParallel | the in-flight bound | unset; DefaultParallel is 8 |
| WithEngine | the event engine | the root's engine, the fleet room's queue |
The kernel also names the queue's priorities and the room's member ids, so an embedder can speak the same order the binary does: PriorityInput 90, PriorityDelegate 70, PriorityWrite 60, PriorityRead 50, PriorityFleet 30, PriorityReview 10.
a worked example
A kernel, a fake provider, one turn
The whole contract in one file: a frontend that returns one line, a policy that passes the session through, a provider that answers done, and loop.Run. Swap the three fakes for the real openai, compact and tui constructors and you have the binary's turn.
package main import ( "context" "fmt" "log" "github.com/mrsirg97-rgb/evt" "github.com/mrsirg97-rgb/rig/v3" "github.com/mrsirg97-rgb/rig/v3/core" "github.com/mrsirg97-rgb/rig/v3/loop" ) // the human seam: one prompt, and the events it sees type frontend struct { prompt string events []evt.Signal } func (f *frontend) Input(ctx context.Context) (string, error) { return f.prompt, nil } func (f *frontend) Notify(ev evt.Signal) { f.events = append(f.events, ev) } // the context seam: hand the transcript through unchanged type passthrough struct{} func (passthrough) Assemble(ctx context.Context, s *core.Session) ([]core.Message, error) { return s.Messages, nil } // the model seam: every stream ends in Done or Fault type stubProvider struct{} func (stubProvider) Stream(ctx context.Context, req core.Request) (<-chan evt.Signal, error) { ch := make(chan evt.Signal, 2) ch <- core.TextDelta{Text: "done"} ch <- core.Done{StopReason: "stop"} close(ch) return ch, nil } func main() { f := &frontend{prompt: "say something"} k := rig.New( rig.WithProvider(stubProvider{}), rig.WithFrontend(f), rig.WithPolicy(passthrough{}), ) if err := loop.Run(context.Background(), k); err != nil { log.Fatal(err) } fmt.Println("events:", len(f.events)) }
What the loop does with that: assemble the context, stream the provider, execute any tool calls through the middleware chain, feed results back, repeat — and return on Done, Fault, or context cancel. A panic in a tool is recovered as that call's error; a fault surfaces through Notify and aborts the turn, and the session survives to its last complete message.
The stores are outside the kernel on purpose. The kernel holds no store handles: todo.Create, todo.Complete and their kin are the board's verbs, and the worker's loop.Run owns the turn. An embedding service wires both, and the worker cannot outlive the process.
the freeze
core/ and loop/ are closed to modification
The frozen surface is the part you can depend on: core/ and loop/ are open to pure addition only. The gate is specs/FREEZE.txt, read by cmd/freeze in the freeze job: every path a PR touches must match a line, and a touched path matching none fails, naming the path and the file. A real change to the frozen surface needs a reopen <path> <version> line, reviewed in the PR.
A new event type, a new seam method alongside the old, a new file in core/: lands without reopening.
One line in specs/FREEZE.txt naming the path and the version that reopened it. The PR says why; the spec says what the surface now is.
A branch carrying -refactor skips the gate; the PR names the reopening.
The structural test behind the freeze is simple: the loop never names a concrete tool, provider, policy, frontend or middleware. If an addition needs the loop to learn a name, it is a change to the surface, not an addition beside it.
extending it
One file, one registration line
Implement core.Tool in tool/<name>/<name>.go; register it in the root's WithTools and the default allow-list.tool/registry.json carries the model's words
Implement core.Provider: every stream ends in Done or Fault.provider/openai · provider/anthropic
Implement core.ContextPolicy; the compaction policy is the one the binary wires.policy/compact
Implement core.Frontend; the TUI adds to the CLI's bytes, never changes them.frontend/tui · frontend/cli · frontend/web · frontend/oneshot
Wrap(ToolExec) ToolExec, composed first-listed innermost; optional TurnStart and Guidelines.middleware/
The SQLite substrate is shared: statements, a schema version, a migration on open.store/ · store/sqlx
A Python plugin needs no Go at all: one file under ~/.rig/plugins/ is one tool, and the model's authoring lands in pending/. That is the plugins page.
the process
Spec first, tests before code
Every behavior is one sentence in specs/ with a test that holds it there; the governing spec is written and agreed before the code. Every Go package carries a PACKAGE.md that is its spec file: what it is, what it includes, how it is consumed, the gotchas. Read it before touching the package; keep it current when behavior changes.
make test # go vet ./... then go test -race ./... make fmt-check # gofmt -l . must be empty; CI runs both # the PR gates CI runs beyond those: go run ./cmd/freeze -branch "$BRANCH" # every touched path matches FREEZE.txt scripts/wire-check # the request bodies, merge-base vs head python3 scripts/readme-measured check # the measured block did not drift
The house patterns are worth reading before a first PR: interface first (the struct is unexported, the constructor returns the interface), one-method seams, a thing is whole when it is constructed, sorted output, one shape per concept, and no comments in Go — the code documents itself and the PACKAGE.md carries the English. CONTRIBUTING.md is the process; AGENTS.md is the map.