Step-by-step tutorial for building a small Glazed CLI that streams events, runs a tool loop, and keeps engine settings hidden in app bootstrap.
This tutorial shows how to build a small streaming agent command with Glazed flags on top of Geppetto's opinionated runner API. The end result is a Cobra command that exposes only business-facing flags such as prompt, profile, and profile-registries, while the application keeps provider configuration and engine bootstrap hidden in app-owned code.
That boundary is important because Geppetto no longer treats profiles as engine-setting overlays. Profiles contribute runtime metadata such as system prompts, middleware uses, and tool names. Your application still owns the final StepSettings that create the engine. If you keep that split clear from the beginning, your CLI stays small, your help output stays readable, and your runtime policy remains explicit.
Use this pattern when you want:
session.Session and enginebuilder.BuilderThe tutorial builds on the current example and helper code in:
geppetto/cmd/examples/runner-glazed-registry-flags/main.gogeppetto/cmd/examples/internal/runnerexample/step_settings.gogeppetto/cmd/examples/internal/runnerexample/profiles/basic.yamlYou will build a command with this shape:
glazed flags
-> decode prompt/profile/profile-registries
-> app-owned hidden StepSettings bootstrap
-> resolve runtime metadata from profile registry
-> register tools
-> start streaming runner with event sink
-> wait for final turn
The command remains small from the user's perspective:
go run ./cmd/examples/runner-glazed-registry-flags \
runner-glazed-registry-flags \
--profile teacher \
--prompt "Use the tool if needed and explain the result."
There are two tempting mistakes when building this kind of CLI.
The first mistake is exposing the full Geppetto flag surface to every user. That is fine for diagnostics and low-level examples, but it makes small tools noisy. Most operator-facing CLIs do not want to expose provider, timeout, temperature, tool execution, and middleware wiring flags directly.
The second mistake is assuming that profile registries should create or mutate engine settings. That used to be a source of architectural confusion. The current model is simpler:
StepSettingspkg/inference/runner consumes the already-resolved runtimeThis tutorial uses that smaller model throughout.
Before you start, make sure you have:
OPENAI_API_KEY set if your hidden base settings use OpenAIIf you need the background first, read these pages:
This is the flow we want:
┌──────────────────────────────────────────────────────┐
│ Glazed command │
│ flags: prompt, profile, profile-registries │
└──────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ App-owned bootstrap │
│ hidden StepSettings from defaults/config/secrets │
└──────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Profile registry resolution │
│ system prompt, middleware uses, tool names │
└──────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ runner.New(...).Start(...) │
│ engine + middleware + tools + session + sinks │
└──────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Streaming events + final turn │
└──────────────────────────────────────────────────────┘
The key design choice is that the Glazed command owns the public flag layer, but the application owns engine bootstrap behind the scenes.
Start by defining a Glazed command that only exposes what the operator should control directly. In this tutorial that is:
That means your command settings struct stays small:
type agentSettings struct {
Prompt string `glazed:"prompt"`
Profile string `glazed:"profile"`
ProfileRegistries string `glazed:"profile-registries"`
}
Then define a dedicated section for registry selection:
func profileRegistrySettingsSection() (schema.Section, error) {
return schema.NewSection(
"profile-settings",
"Profile settings",
schema.WithFields(
fields.New("profile", fields.TypeString,
fields.WithHelp("Profile slug to resolve"),
fields.WithDefault("concise"),
),
fields.New("profile-registries", fields.TypeString,
fields.WithHelp("Comma-separated profile registry sources"),
fields.WithDefault(runnerexample.ExampleProfileRegistryPath()),
),
),
)
}
Why this matters:
glaze help output short and teachableThis is the architectural center of the tutorial.
Do not ask the registry to create engine settings. Instead, build base StepSettings in app code. In a production app that usually means some combination of:
The current example uses a defaults-only bootstrap helper:
stepSettings, err := runnerexample.BaseStepSettingsFromDefaults()
if err != nil {
return err
}
That helper mirrors Pinocchio's bootstrap shape:
func BaseStepSettingsFromDefaults() (*settings.StepSettings, error) {
sections_, err := geppettosections.CreateGeppettoSections()
if err != nil { return nil, err }
schema_ := schema.NewSchema(schema.WithSections(sections_...))
parsedValues := values.New()
err = sources.Execute(
schema_,
parsedValues,
sources.FromDefaults(fields.WithSource(fields.SourceDefaults)),
)
if err != nil { return nil, err }
return settings.NewStepSettingsFromParsedValues(parsedValues)
}
In a real binary, the same pattern often becomes:
defaults
+ app config file
+ env vars
+ optional explicit config override
-> final base StepSettings
That is the correct place to wire provider credentials, default model choice, client timeout, and related engine-level concerns.
Once you have base StepSettings, use the registry only for runtime metadata selection.
The example helper does exactly that:
runtime, closeRegistry, err := runnerexample.ResolveRuntimeFromRegistry(
ctx,
stepSettings,
s.ProfileRegistries,
s.Profile,
)
if err != nil {
return err
}
defer closeRegistry()
The important thing to understand is what this call contributes.
It does contribute:
SystemPromptMiddlewareUsesToolNamesRuntimeKeyRuntimeFingerprintProfileVersionIt does not contribute:
Conceptually, this step looks like:
base StepSettings (app-owned)
+ resolved profile runtime metadata
-> runner.Runtime
That is much easier to reason about than the old “patch engine settings through the profile layer” model.
A streaming agent usually needs at least one tool to make the loop interesting. With the opinionated runner, the easiest path is to register a function tool when constructing the runner.
Example:
type WeatherRequest struct {
Location string `json:"location"`
}
type WeatherResponse struct {
Summary string `json:"summary"`
}
func weatherTool(req WeatherRequest) (WeatherResponse, error) {
return WeatherResponse{
Summary: "Sunny and mild in " + req.Location,
}, nil
}
r := runner.New(
runner.WithFuncTool(
"weather",
"Return a short weather summary for a location",
weatherTool,
),
)
This matters because the runner will build the tool registry for you, and the resolved profile can still decide whether the tool should be exposed by including or omitting the tool name in runtime.ToolNames.
The pattern is:
tool registrars define what the app can do
profile runtime decides which of those tools are visible in this run
That separation keeps app capabilities and profile policy distinct.
If you want live output, call Start(...) instead of Run(...).
You need an event sink implementation that receives events as inference progresses. For a minimal terminal-oriented example, a tiny stdout sink is enough:
type stdoutSink struct{}
func (s *stdoutSink) PublishEvent(event events.Event) error {
fmt.Printf("event: %s\n", event.Type())
return nil
}
Then pass it into the start request:
prepared, handle, err := r.Start(ctx, runner.StartRequest{
Prompt: s.Prompt,
Runtime: runtime,
EventSinks: []events.EventSink{
&stdoutSink{},
},
})
if err != nil {
return err
}
Why this matters:
Start(...) gives you immediate access to streaming behaviorprepared.Session is available for inspection or custom coordinationhandle.Wait() still gives you the final turn after streaming completesThat makes the API work for both real-time UIs and CLI tools.
After starting the run, wait for completion and print the final turn.
fmt.Printf("session: %s\n", prepared.Session.SessionID)
out, err := handle.Wait()
if err != nil {
return err
}
turns.FprintTurn(w, out)
This is an important point for new contributors: streaming and final result handling are not competing patterns. They are the two halves of the same execution flow.
Wait() gives you the completed final turnHere is the combined shape in pseudocode:
func (c *agentCommand) RunIntoWriter(ctx context.Context, parsedValues *values.Values, w io.Writer) error {
s := decodeAgentSettings(parsedValues)
stepSettings, err := BaseStepSettingsFromDefaults()
if err != nil {
return err
}
runtime, closeRegistry, err := ResolveRuntimeFromRegistry(
ctx,
stepSettings,
s.ProfileRegistries,
s.Profile,
)
if err != nil {
return err
}
defer closeRegistry()
r := runner.New(
runner.WithFuncTool("weather", "Weather lookup", weatherTool),
)
prepared, handle, err := r.Start(ctx, runner.StartRequest{
Prompt: s.Prompt,
Runtime: runtime,
EventSinks: []events.EventSink{
&stdoutSink{},
},
})
if err != nil {
return err
}
fmt.Fprintf(w, "session: %s\n", prepared.Session.SessionID)
out, err := handle.Wait()
if err != nil {
return err
}
turns.FprintTurn(w, out)
return nil
}
This is the opinionated design target for a small agent CLI:
Geppetto now has two Glazed example boundaries on purpose.
Use the full-flags pattern when:
Use the registry-flags pattern when:
That is the practical difference between:
cmd/examples/runner-glazed-full-flags/cmd/examples/runner-glazed-registry-flags/A clean implementation usually ends up split like this:
cmd/my-agent/main.go
cmd/my-agent/agent_command.go
cmd/my-agent/profile_section.go
internal/myagent/runtime_bootstrap.go
internal/myagent/tools.go
internal/myagent/events.go
pkg/doc/tutorials/build-streaming-tool-loop-agent-with-glazed-flags.md
This is useful because it prevents the Cobra/Glazed layer from swallowing all of the runtime logic in one large file.
New contributors often hit the same mistakes:
StepSettings from the profile registry instead of from app bootstrapRun(...) when they actually need streaming behavior from Start(...)If the command feels confused, check whether responsibilities are crossing boundaries:
Each one should stay in its own layer.
| Problem | Cause | Solution |
|---|---|---|
| The command streams no events | You called Run(...) instead of Start(...), or forgot to attach an event sink | Use Start(...) and pass EventSinks in the start request |
| The model ignores your tool | The tool was registered, but the resolved runtime filtered it out via ToolNames | Check the profile runtime and ensure the tool name appears in the resolved profile |
| The command asks for too many flags | You are using full Geppetto sections for a small CLI | Switch to the registry-flags pattern and keep StepSettings bootstrap hidden |
| Provider credentials are missing | The profile registry does not supply engine settings | Bootstrap base StepSettings from config/env/secrets in app code |
| The registry works in YAML but not SQLite | The registry handle may not be closed or the source string may be wrong | Verify the profile-registries source entry and defer closeRegistry() |
Prepare, Start, and RunExample files:
geppetto/cmd/examples/runner-streaming/main.gogeppetto/cmd/examples/runner-tools/main.gogeppetto/cmd/examples/runner-glazed-registry-flags/main.gogeppetto/cmd/examples/internal/runnerexample/step_settings.go