Skip to content

Embed Harbor headless (serving the Protocol is an opt-in sibling)

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. Headless is the default story of this recipe; when you DO want to expose the wire surface, the additive opt-in sibling is sdk/server — see Serve the Protocol from your binary at the end.

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.go executes this recipe's path through sdk/ imports only, and the Phase 112b external compile gate (scripts/smoke/phase-112b.sh) keeps external buildability true on every preflight.

The pieces

StepSymbolPhase
Driver registrations_ "github.com/hurtener/Harbor/sdk/drivers/prod"110c / 112a (D-196, D-205)
Config baselineconfig.Defaults()110c (D-196)
Headless validationcfg.ValidateCore()110c (D-196)
The ONE fan-outassemble.Assemble(ctx, cfg, opts)110d (D-197)
The one-call runnerStack.RunOnce(ctx, goal, identity, opts...)132 (D-265)
The RunContext factoryrunctx.NewRunContext(ctx, src, quad, goal, opts...)132 (D-265)
Tool dispatchStack.Executor (the promoted dispatch.NewToolExecutor concrete)110a (D-194)
The run loopStack.RunLoop.Run(ctx, steering.RunSpec{...})53 / 83i
The answerplanner.AnswerEnvelope110a (D-194)
The tool-invocation countplanner.CountToolInvocations(traj)D-274
Typed output (hand-authored schema)assemble.WithOutputSchema(schema)143 (D-272)
Typed output (schema derived from a Go type)assemble.RunTyped[T](ctx, stack, goal, id, opts...)144 (D-273)

1. Imports

go
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.

go
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.

go
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. Two ways to run a goal:

  • 4a — Stack.RunOnce (the shorthand). One blocking call builds the RunContext, drives the run loop, and returns the answer envelope. Reach for this first.
  • 4b — drive RunLoop.Run yourself. The explicit path when you need to hand-shape the RunContext / RunSpec (a custom catalog filter, pre-resolved input artifacts, a non-default budget). Both are test-gated.

4a. The one-call runner — Stack.RunOnce

RunOnce is the production counterpart to the hand-built loop below: it composes the same memory / skills / artifact / streaming projections through runctx.NewRunContext, drives the assembled RunLoop, and returns the terminal planner.AnswerEnvelope. It BLOCKS on the calling goroutine (no Sync suffix) — wrap it in your own go for concurrency; the stack is safe to share across goroutines (D-025).

go
env, err := stack.RunOnce(ctx,
    "Summarise the latest deployment status.",
    identity.Identity{TenantID: "acme", UserID: "u-42", SessionID: "s-1"})
if err != nil {
    return fmt.Errorf("run: %w", err)
}
fmt.Println(env.Answer) // env.FinishReason == "goal" on success

A fresh run ID is synthesised per call; pin one with assemble.WithRunID("…"), and pre-resolve operator-uploaded inputs with assemble.WithInputArtifacts(ids...). A stack assembled without a planner/run loop (no LLM driver, or SkipRunLoop) returns assemble.ErrNotRunnable — never a silent no-op. The checked-in examples/embed-runonce/ is this exact program; the Phase 112b smoke compiles it.

Streaming the run — assemble.WithStream. Pass a WithStream sink to observe token deltas, planner-step boundaries, and tool dispatches as they occur. RunOnce still blocks and returns the same envelope; the sink fires synchronously on the run goroutine, so every StreamEvent arrives before RunOnce returns.

go
env, err := stack.RunOnce(ctx, goal,
    identity.Identity{TenantID: "acme", UserID: "u-42", SessionID: "s-1"},
    assemble.WithStream(func(e assemble.StreamEvent) {
        switch e.Kind {
        case assemble.StreamToken:
            fmt.Print(e.Text) // live answer (or reasoning) tokens
        case assemble.StreamToolDispatched:
            fmt.Println("\n[tool dispatched]")
        case assemble.StreamStep:
            // a planner step finished streaming
        }
    }))

The sink runs on the run goroutine — keep it non-blocking (push to a buffered channel for fan-out). Each RunOnce call captures its own sink, so concurrent runs against one shared Stack never cross chunks.

Note the tool_dispatched cardinality (D-274): the sink receives one event PER dispatched tool — a parallel tool call emits N events (one per branch) — and task spawn/await decisions emit none (v1.8 emitted one event per spawn/await dispatch; they are not tool invocations).

Typed output — assemble.WithOutputSchema. Ask a run for a schema-conforming final answer in one line. The terminal answer is validated against the JSON Schema and delivered as the envelope's additive AnswerPayload (the validated raw JSON); Answer carries the same payload's string rendering. A schema-invalid answer after the correction budget returns planner.ErrOutputInvalid — never a silent fallback to unvalidated text (§13). A nil/empty schema is a loud config error.

go
schema := json.RawMessage(`{
    "type": "object",
    "required": ["sentiment"],
    "properties": {"sentiment": {"type": "string", "enum": ["positive","negative","neutral"]}},
    "additionalProperties": false
}`)
env, err := stack.RunOnce(ctx, "Classify the sentiment.",
    identity.Identity{TenantID: "acme", UserID: "u-42", SessionID: "s-1"},
    assemble.WithOutputSchema(schema))
if err != nil {
    return fmt.Errorf("typed run: %w", err) // errors.Is(err, planner.ErrOutputInvalid) on schema failure
}
var out struct{ Sentiment string `json:"sentiment"` }
_ = json.Unmarshal(env.AnswerPayload, &out) // the validated payload

Under the hood the run rides the profile's existing structured-output strategy (the OutputMode selection + downgrade chain) and the validate-and-retry loop bounded by ModelProfile.MaxRetries — no new knob. Streaming caveat: WithOutputSchema composes with WithStream, but ALL token chunks — content AND reasoning — are SUPPRESSED for a schema-constrained run — a validate-and-retry loop cannot retract tokens it already streamed, so the validated answer arrives once, in the envelope. step and tool_dispatched events still stream (D-272); expect MORE step events per turn than a plain run when a corrective retry / downgrade attempt fires (each attempt is its own LLM call and its own step boundary).

Typed output — assemble.RunTyped[T]. The generic sugar over the two lines above: derive the schema FROM a Go type instead of hand- authoring the JSON-Schema document, and get the validated answer back already unmarshaled into that type.

go
type SentimentReport struct {
    Sentiment  string  `json:"sentiment"`
    Confidence float64 `json:"confidence,omitempty"`
}

report, env, err := assemble.RunTyped[SentimentReport](ctx, stack,
    "Classify the sentiment.",
    identity.Identity{TenantID: "acme", UserID: "u-42", SessionID: "s-1"})
if err != nil {
    return fmt.Errorf("typed run: %w", err) // errors.Is(err, planner.ErrOutputInvalid) on schema failure
}
fmt.Println(report.Sentiment, env.FinishReason)

RunTyped derives the JSON Schema from SentimentReport via the SAME reflection-based deriver sdk/tools/inproc.RegisterFunc uses for tool registration (internal/tools/schema, promoted out of the tool driver in Phase 144 — one implementation, two consumers), appends it as WithOutputSchema, drives RunOnce, and unmarshals the validated AnswerPayload into T. Three binding failure modes, all loud:

  • An unsupported T (a non-empty interface, a channel or function field, a cyclic structure) fails at CALL TIME, before any run starts or any LLM spend — the error names the offending Go field. The derivation internals stay private to the facade (D-273's non-goals — internal/tools/schema is not re-exported), so an sdk/-only caller inspects the error text rather than comparing against an internal sentinel.
  • A caller-supplied WithOutputSchema alongside RunTyped is a loud conflict (assemble.ErrRunTypedSchemaConflict), never a silent override — RunTyped owns the schema for the call it drives. Drop the explicit option, or call RunOnce + WithOutputSchema directly when a hand-authored schema is required.
  • A schema-valid payload that still fails to unmarshal into T (a narrower Go numeric type than the derived schema's unconstrained "integer"/"number", for instance) is assemble.ErrRunTypedUnmarshalRunTyped never returns a zero value alongside a nil error.

RunTyped is a free function over the shared immutable Stack — it is deliberately NOT a stateful binding object, and deliberately NOT named Agent (that noun is taken twice in Harbor's vocabulary: harbortest.Agent and the Agent Registry's registration entities, D-059/D-273). Identity stays a per-call argument, exactly like RunOnce.

4b. Drive the run loop yourself

Drive the shared RunLoop directly — the same loop harbor dev drives per spawned task — with the assembled planner and executor.

go
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:

go
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: planner.CountToolInvocations(traj),
}
fmt.Println(envelope.Answer)

ToolCallsSeen is the true tool-invocation count, not the trajectory step count: planner.CountToolInvocations counts a CallParallel step once PER branch and excludes SpawnTask / AwaitTask steps entirely (D-274) — don't reconstruct it with len(traj.Steps).

fin.Reason == planner.FinishGoal is the success case; any other FinishReason maps to a task-error code via planner.TaskErrorCodeForFinish.

On a schema-constrained run (WithOutputSchema, §4a), the envelope also carries AnswerPayload — the validated raw JSON — which Answer mirrors as a string. json.Unmarshal(env.AnswerPayload, &yourType) is the typed read; the RunOnce shorthand fills it for you.

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.IdentityTiers before calling assemble.Assemble — the assembly builds the enforcement subsystem (MaxTokens → rate limit → cost ceiling) and installs it via governance.SetFactory before llm.Open composes the wrapper chain. Configured tiers then reject with governance.ErrBudgetExceeded / ErrRateLimited / ErrMaxTokensExceeded and emit the matching governance.* events on stack.Bus. Empty tiers stay fully latent (D-044).
  • N stacks with different tier maps: SetFactory is process-global (the second caller wins — see its godoc), so skip it and compose per stack instead:
go
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.

Serve the Protocol from your binary (the opt-in sibling)

Everything above runs the agent headless — no listener. When you want the same compiled agent to expose the Harbor Protocol over the wire at parity with the stock harbor serve binary, swap assemble.Assemble + RunOnce for the sdk/server facade:

go
import (
    _ "github.com/hurtener/Harbor/sdk/drivers/prod"

    "github.com/hurtener/Harbor/sdk/config"
    "github.com/hurtener/Harbor/sdk/server"
)

cfg, err := config.Load(ctx, "harbor.yaml")
// ... handle err ...

h, err := server.Open(ctx, cfg, server.Options{
    RegisterCatalog: RegisterTools, // your project's tool registrar
})
// ... handle err ...
defer h.Close(ctx)

if err := h.Serve(ctx); err != nil { // blocks until ctx cancels
    // ...
}

Three things distinguish the serving path from the headless one:

  • Production-only by construction. server.Open ALWAYS builds the JWKS verifier from cfg.Identity and re-runs the full config Validate — a missing JWKS source fails Open loud, naming the field. There is no dev-signer and no mock knob (unlike the headless recipe's offline-CI variation). The local-dev loop is the three harbor token commands: keygen → set identity.jwks_filemint.
  • Compiled tools keep their declared policy. RegisterCatalog runs at the runtime's pre-policy catalog seam, so a tool it registers gets the identical tools.entries[] approval / OAuth / policy wrapping an operator's YAML tool gets — registering tools any other way (after Open returns) skips that shell.
  • You don't hand-write main.go. harbor scaffold --with-server emits exactly this cmd/<agent>/main.go for you (the --config / --port / --bind flag trio included). Reach for the snippet above only when embedding the server into a larger program.

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.spawned events; you then own the subscriber that calls stack.RunLoop.Run per task (the shape cmd/harbor's per-task driver implements).
  • Skip what you don't need: assemble.Options.SkipCatalog / SkipSteering / SkipRunLoop build partial stacks for harness-style embedding (the harbortest/devstack kit 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.go does: a custom-provider LLM entry (loopback BaseURL, env-var dummy key) plus assemble.Options.PlannerOverride with the deterministic planner — real drivers, no network.

Apache-2.0 licensed — see LICENSE.