Embed Harbor headless (no harbor dev, no Protocol server)
Run a Harbor agent runtime inside your own Go program — no CLI, no HTTP listener, no Console. One call turns a validated config into a running stack; you drive a goal through the planner/run-loop and read the answer.
This recipe is acceptance-gated: its end-to-end path is executed by test/integration/phase110d_assemble_test.go, so every snippet references real exported symbols (Phase 110d, D-197).
Import paths. This recipe's snippets use the public
sdk/facade (RFC §3.6, D-204/D-205) — the curated alias tree that makes every symbol below importable from an EXTERNAL Go module. Each alias IS the internal type, so the same snippets work verbatim for in-module embedding too.test/integration/phase112a_sdk_facade_test.goexecutes this recipe's path throughsdk/imports only, and the Phase 112b external compile gate (scripts/smoke/phase-112b.sh) keeps external buildability true on every preflight.
The pieces
| Step | Symbol | Phase |
|---|---|---|
| Driver registrations | _ "github.com/hurtener/Harbor/sdk/drivers/prod" | 110c / 112a (D-196, D-205) |
| Config baseline | config.Defaults() | 110c (D-196) |
| Headless validation | cfg.ValidateCore() | 110c (D-196) |
| The ONE fan-out | assemble.Assemble(ctx, cfg, opts) | 110d (D-197) |
| Tool dispatch | Stack.Executor (the promoted dispatch.NewToolExecutor concrete) | 110a (D-194) |
| The run loop | Stack.RunLoop.Run(ctx, steering.RunSpec{...}) | 53 / 83i |
| The answer | planner.AnswerEnvelope | 110a (D-194) |
1. Imports
import (
"context"
"fmt"
"log/slog"
// The production driver aggregator — the single sanctioned
// blank-import home (§4.4), via its public facade twin. Without
// it every Open fails loud with "unknown driver".
_ "github.com/hurtener/Harbor/sdk/drivers/prod"
"github.com/hurtener/Harbor/sdk/assemble"
"github.com/hurtener/Harbor/sdk/config"
"github.com/hurtener/Harbor/sdk/identity"
"github.com/hurtener/Harbor/sdk/planner"
"github.com/hurtener/Harbor/sdk/steering"
"github.com/hurtener/Harbor/sdk/tools"
)2. Build and validate the config
config.Defaults() is the same baseline config.Load applies to a harbor.yaml; a hand-built config therefore behaves exactly like a loaded one. ValidateCore() runs every section validator except the Protocol-server JWT ceremony a headless embedder never serves.
cfg := config.Defaults()
cfg.LLM.Driver = "bifrost"
cfg.LLM.Provider = "openrouter"
cfg.LLM.Model = "anthropic/claude-sonnet-4"
cfg.LLM.APIKey = "env.OPENROUTER_API_KEY" // env-var indirection — never inline a key
if err := cfg.ValidateCore(); err != nil {
return fmt.Errorf("config: %w", err)
}Everything else — state, events, artifacts, tasks, memory — defaults to the in-memory drivers. Point them at sqlite / postgres DSNs for durability; select events.driver: durable and the assembly shares the runtime's StateStore with the durable event log automatically (events.OpenWith, Phase 110d).
3. Assemble the stack
One call composes the full dependency-ordered runtime — stores, bus, LLM, memory, skills, tasks, tool catalog (builtins + OAuth providers + approval gates + MCP attach), sessions, agent registry, pause coordinator, planner, run loop — with reverse-order closers and partial-failure cleanup.
ctx := context.Background()
stack, err := assemble.Assemble(ctx, cfg, assemble.Options{
Logger: slog.Default(),
})
if err != nil {
if stack != nil {
_ = stack.Close(ctx) // drain whatever opened before the failure
}
return fmt.Errorf("assemble: %w", err)
}
defer stack.Close(ctx)Register your own in-process tools before assembling via assemble.Options.PreRegisterTools, or after assembling via stack.Catalog.Register(...).
4. Run one goal
Identity is mandatory (§6): every run carries the (tenant, user, session) triple plus a run ID. Drive the shared RunLoop directly — the same loop harbor dev drives per spawned task — with the assembled planner and executor.
q := identity.Quadruple{
Identity: identity.Identity{
TenantID: "acme",
UserID: "u-42",
SessionID: "s-1",
},
RunID: "run-1",
}
goal := "Summarise the latest deployment status."
traj := &planner.Trajectory{Query: goal}
fin, err := stack.RunLoop.Run(ctx, steering.RunSpec{
Planner: stack.Planner,
Base: planner.RunContext{
Quadruple: q,
Query: goal,
Goal: goal,
Trajectory: traj,
RepairCounters: &planner.RepairCounters{},
Catalog: tools.NewPlannerView(stack.Catalog, tools.CatalogFilter{
TenantID: q.TenantID,
UserID: q.UserID,
SessionID: q.SessionID,
}),
},
ToolExecutor: stack.Executor,
MaxSteps: cfg.Planner.MaxSteps,
})
if err != nil {
return fmt.Errorf("run: %w", err)
}5. Read the answer
planner.AnswerEnvelope is the canonical answer shape the per-task run-loop drivers marshal onto tasks.TaskResult — build the same envelope from the terminal Finish:
answer := ""
if s, ok := fin.Payload.(string); ok {
answer = s
} else if m, ok := fin.Payload.(map[string]any); ok {
if v, ok := m["answer"].(string); ok {
answer = v
}
}
envelope := planner.AnswerEnvelope{
Answer: answer,
FinishReason: string(fin.Reason),
ToolCallsSeen: len(traj.Steps),
}
fmt.Println(envelope.Answer)fin.Reason == planner.FinishGoal is the success case; any other FinishReason maps to a task-error code via planner.TaskErrorCodeForFinish.
6. Shut down
stack.Close(ctx) runs every subsystem's Close in reverse dependency order and is idempotent. After Close returns, the stack's goroutines (notification subscriber, session GC sweeper, metrics bridge) have drained — the integration test asserts the goroutine baseline is restored.
Enforce governance headless
Two paths, depending on how many stacks your process runs:
- One stack (the common case): just populate
cfg.Governance.DefaultTier+cfg.Governance.IdentityTiersbefore callingassemble.Assemble— the assembly builds the enforcement subsystem (MaxTokens → rate limit → cost ceiling) and installs it viagovernance.SetFactorybeforellm.Opencomposes the wrapper chain. Configured tiers then reject withgovernance.ErrBudgetExceeded/ErrRateLimited/ErrMaxTokensExceededand emit the matchinggovernance.*events onstack.Bus. Empty tiers stay fully latent (D-044). - N stacks with different tier maps:
SetFactoryis process-global (the second caller wins — see its godoc), so skip it and compose per stack instead:
import "github.com/hurtener/Harbor/sdk/governance"
sub, err := governance.NewSubsystemFromConfig(
governance.ConfigFromOperator(cfg.Governance), // or a hand-built governance.Config
stack.State, stack.Bus)
if err != nil {
return err // fail-loud: tiers without store/bus are a misconfig
}
if sub != nil { // nil = empty tiers = the sanctioned latent default
client = governance.Wrap(client, sub) // governance stays outermost (D-043)
}NewSubsystemFromConfig + governance.Wrap are the documented multi-runtime path (Phase 111a, D-198): no process-global state, one Subsystem per stack, accumulator state persisted in that stack's StateStore.
Variations
- Observe events: subscribe before running —
stack.Bus.Subscribe(ctx, events.Filter{Tenant: ..., User: ..., Session: ...}). - Spawn through the task registry instead of driving the RunLoop directly:
stack.Tasks.Spawn(...)gives you the task FSM +task.spawnedevents; you then own the subscriber that callsstack.RunLoop.Runper task (the shapecmd/harbor's per-task driver implements). - Skip what you don't need:
assemble.Options.SkipCatalog/SkipSteering/SkipRunLoopbuild partial stacks for harness-style embedding (theharbortest/devstackkit uses exactly these knobs). - Mock LLM for CI smoke: module-internal only — the facade deliberately omits the dev-only mock driver (D-089, D-205), so an external module cannot seat it. For offline CI against the facade, do what
test/integration/phase112a_sdk_facade_test.godoes: a custom-provider LLM entry (loopback BaseURL, env-var dummy key) plusassemble.Options.PlannerOverridewith the deterministic planner — real drivers, no network.