Help us shape the future of Go! Take the 2026 Go Developer Survey

steerloop

module
v0.2.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 25, 2026 License: Apache-2.0

README

steerloop

The loop inside Claude Code, in 2,000 lines of Go you can read in a weekend.

Every agentic CLI (Claude Code, Codex, Cursor's agent, aider) is the same thing: a loop that calls a model, runs the tool it asked for, feeds the result back and repeats. In the real tools that loop is buried under a hundred thousand lines of UI and telemetry. steerloop is the loop on its own: the permission prompt, Esc, typing while it works, the transcript file, the retry after a 529, each one a test you can read and thirty lines you can change. The model is an interface, so a JSON file can play it: every behaviour runs offline, in seconds, for free.

It is the specimen for the video series How Claude Code Works. It is also a library: a controllable agent harness built on CloudWeGo Eino, with control over a run that is already in flight:

  • Steer it. Stop it, inject a user message, or change its iteration cap and temperature while it runs.
  • Watch it. A typed event stream: model turns, tool calls, tool results, the final outcome.
  • Pause it for a person. Hand named tool calls to a function that can wait for a human answer.
  • Survive a flaky provider. Retries on transient failures, and a watchdog that aborts a streamed turn that stops producing chunks.
  • Audit it. One record per model turn with the request, the reply, token counts and cost.

It also ships a CLI, steerloop, that runs a loop from the terminal over command-line tools you declare in a YAML file.

Status: v0. The API may change before v1.

Install

go get github.com/mittalkanishk/steerloop                              # library
go install github.com/mittalkanishk/steerloop/cmd/steerloop@latest     # CLI

Requires Go 1.26 or later.

Library

loop, err := steerloop.New(steerloop.DefaultConfig(), chatModel, tools, executor, nil)
if err != nil {
	return err
}

messages := []*schema.Message{{Role: schema.User, Content: "Upper-case the word hello."}}
for evt := range loop.Start(ctx, messages) {
	switch data := evt.Data.(type) {
	case steerloop.ToolCallData:
		fmt.Println("tool call:", data.Name, data.Arguments)
	case steerloop.ToolResultData:
		fmt.Println("tool result:", data.Result)
	case steerloop.DoneData:
		fmt.Println(data.Reason, data.FinalContent)
	}
}
  • chatModel is any Eino model.ToolCallingChatModel (Anthropic, OpenAI, Ollama and others from eino-ext).
  • tools are Eino schema.ToolInfo values; steerloop.NewToolInfo builds one from a JSON Schema.
  • executor implements ExecuteTool(ctx, name, arguments) (string, error). An error is returned to the model as the tool's result, so it can react.

Runnable programs are in examples/: minimal, steer and ask_user. They use a scripted model and need no API key.

Controls
Call Effect
loop.Stop() Ends the run at the next step boundary, or at once if a pause is waiting. Cannot be lost. Does not interrupt a model call or tool already running; cancel the context for that.
loop.Inject(text) Adds a user message the model sees on its next call. Inside a pause, it is the reply being waited for.
loop.SendControlSignal(NewModifyConfigControl(...)) Changes MaxIterations, Temperature or EnableReflection.

Registry[K] keeps running loops by a key of your choice (a session or user ID), so a different goroutine or request handler can stop or steer them.

Stop conditions
Value The loop ends when
StopOnNoToolCalls the model replies without calling a tool
StopOnFinishTool the model calls the tool named finish; its arguments are the result
StopOnExplicit it is stopped, hits MaxIterations, or times out
Events

Every event has Type, Data, Iteration and Timestamp.

Type Data When
thinking ThinkingData before each model call
llm_turn LLMTurnData after a model call that asked for tools: request, reply, tokens, cost
tool_call ToolCallData before a tool runs
tool_result ToolResultData after a tool runs
response ResponseData the model replied with text
reflection ReflectionData the optional self-review reply
progress ProgressData a control was applied
awaiting_input AwaitingInputData a pause is waiting for a person
error ErrorData a failure; Fatal says whether the run ended
done DoneData always last, exactly once

Intermediate events are dropped when the channel is full, and DoneData.DroppedEvents says how many. The done event is not dropped: the loop waits for a reader. loop.Result() returns the outcome without reading events at all.

Pausing for a person
cfg.Pauses = map[string]steerloop.PauseFunc{
	"ask_user": func(ctx context.Context, in steerloop.PauseIn) (string, bool) {
		in.Emit(steerloop.EventAwaitingInput, steerloop.AwaitingInputData{Question: "Which file?"})
		return in.Block(ctx) // returns the next loop.Inject
	},
}

The reply becomes the tool's result. Returning false ends the run with reason awaiting_human, for callers that resume later with a new loop.

Testing your agent

steerlooptest.ScriptedModel plays a list of replies (text, tool calls, errors, slow or chunked streams) so loop behaviour can be tested without a provider. steerlooptest.RecordingExecutor records tool calls.

CLI

export ANTHROPIC_API_KEY=...      # or OPENAI_API_KEY
steerloop run --model anthropic:<model> --tools examples/tools.yaml \
  --prompt "How many lines is README.md, and which files mention Registry?"

The prompt is required and always goes in --prompt. Words after the flags are rejected, and stdin is never read as the prompt; it stays free for answering the run.

The trace goes to stderr and the final answer to stdout:

model anthropic:<model> · stop no-tool-calls · max 12 steps
[step 1]
  → count_lines {"path":"README.md"}
  ← count_lines: 180 README.md
[step 2]
done: finished · 2 step(s) · 1482 tokens
README.md has 180 lines.

While it runs, type to steer it:

Input Effect
any text injected as a user message before the next model call
/stop graceful stop
/max <n>, /temp <x>, /reflect on|off change the configuration
Ctrl-C graceful stop; a second Ctrl-C aborts

Other commands and flags:

--output jsonl one JSON event per line on stdout
--record run.jsonl also write every event to a file
steerloop replay run.jsonl re-render a recording; makes no model call and runs no tool
--ask give the model an ask_user tool that asks on the terminal
--control file read controls as JSON lines from a file or FIFO, for scripting
--stop finish-tool run until the model calls finish
--model script:file.json play back a scripted model; no key, no network
steerloop tools validate file check a manifest

Exit codes: 0 finished, 1 error, 2 usage, 3 max iterations, 4 timeout, 5 awaiting a human, 130 stopped.

Tool manifest
tools:
  - name: grep_repo
    description: Search files under the current directory for a regular expression.
    parameters:
      type: object
      properties:
        pattern: { type: string }
      required: [pattern]
    command: ["grep", "-rn", "--", "{{.pattern}}", "."]
    timeout: 20s
    confirm: false

What the CLI guarantees about a manifest tool:

  • The command is an argv list and is never passed to a shell. A value expands inside its own element, so ; rm -rf / arrives at the program as literal text.
  • A value that would begin an element with - is rejected unless the tool sets allow_flag_values: true. Put -- before values where the program supports it.
  • Arguments are checked against parameters before the command runs: required names, unknown names, top-level types and enums.
  • confirm: true asks before each run. With no terminal and no --yes, the tool is not run.
  • Output is capped (64 KiB by default) and a non-zero exit is returned to the model as an error.
  • Only declared tools can run. There is no built-in shell or file tool.

A tool still runs with your permissions and environment. The manifest is the security boundary, so declare narrow tools.

Limitations

  • Tool calls in one model turn run one after another, not in parallel.
  • Stop waits for the current model call or tool to return.
  • The loop holds the conversation in memory. Persisting and resuming it is the caller's job; GetRunningMessages and the llm_turn events give you the data.
  • HistoryCompactor is a hook. No summarization strategy is included.
  • The CLI's argument validation covers required names, unknown names, top-level types and enums, not the whole of JSON Schema.
  • The CLI supports Anthropic and OpenAI models. The library accepts any Eino chat model.
  • MCP servers are not supported as a tool source yet.

Development

go test -race ./...                       # no network, no API key
go test ./cmd/steerloop -update           # rewrite golden files
STEERLOOP_LIVE=1 STEERLOOP_LIVE_MODEL=openai:<model> go test -run LiveSmoke ./cmd/steerloop

License

Apache-2.0. See LICENSE.

Directories

Path Synopsis
cmd
steerloop command
Command steerloop runs an agent loop from the terminal: run, replay, tools validate, version and help.
Command steerloop runs an agent loop from the terminal: run, replay, tools validate, version and help.
steerloop/internal/cli
Package cli is the leaf of steerloop: command dispatch, the environment contract and exit codes.
Package cli is the leaf of steerloop: command dispatch, the environment contract and exit codes.
steerloop/internal/cmds/help
Package helpcmd is `steerloop help`.
Package helpcmd is `steerloop help`.
steerloop/internal/cmds/run
Package runcmd is `steerloop run`.
Package runcmd is `steerloop run`.
steerloop/internal/cmds/version
Package versioncmd is `steerloop version`.
Package versioncmd is `steerloop version`.
steerloop/internal/interrupt
Package interrupt maps Ctrl-C onto a running loop.
Package interrupt maps Ctrl-C onto a running loop.
steerloop/internal/osenv
Package osenv is the real cli.Environment: the process's own env and streams.
Package osenv is the real cli.Environment: the process's own env and streams.
steerloop/internal/render
Package render prints a run for a person at a terminal.
Package render prints a run for a person at a terminal.
steerloop/internal/steering
Package steering feeds a person's input into a running loop.
Package steering feeds a person's input into a running loop.
examples
ask_user command
Ask_user shows a pause tool: when the model calls ask_user, the loop hands the call to a pause.Handler that waits for a person, and the answer becomes the tool's result.
Ask_user shows a pause tool: when the model calls ask_user, the loop hands the call to a pause.Handler that waits for a person, and the answer becomes the tool's result.
minimal command
Minimal shows the smallest useful loop: a model, one tool, and the event stream.
Minimal shows the smallest useful loop: a model, one tool, and the event stream.
steer command
Steer shows the three controls on a running loop: inject a message, change the configuration, and stop.
Steer shows the three controls on a running loop: inject a message, change the configuration, and stop.
internal
eventlog
Package eventlog writes a run's events as JSON lines, for other programs and for recordings.
Package eventlog writes a run's events as JSON lines, for other programs and for recordings.
gates
Package gates holds pause handlers for tool calls that need a person before they can finish.
Package gates holds pause handlers for tool calls that need a person before they can finish.
provider
Package provider builds an Eino chat model from a "provider:model" string.
Package provider builds an Eino chat model from a "provider:model" string.
tools
Package exectool runs command-line tools declared in a YAML manifest.
Package exectool runs command-line tools declared in a YAML manifest.
Package loop is a controllable agent loop for CloudWeGo Eino, in an interface-first, nested shape.
Package loop is a controllable agent loop for CloudWeGo Eino, in an interface-first, nested shape.
event
Package event is the vocabulary of a running loop: every event type and payload.
Package event is the vocabulary of a running loop: every event type and payload.
internal/audit
Package audit builds the LLMTurn record of each model call that led to tool calls.
Package audit builds the LLMTurn record of each model call that led to tool calls.
internal/control
Package control holds the controls sent to a running loop: stop, inject a message, change settings.
Package control holds the controls sent to a running loop: stop, inject a message, change settings.
internal/defaults
Package defaults is what runs when the caller supplies nothing: one no-op or built-in implementation per optional interface, so no other package has to check for nil.
Package defaults is what runs when the caller supplies nothing: one no-op or built-in implementation per optional interface, so no other package has to check for nil.
internal/dispatch
Package dispatch runs one tool call: through the ToolExecutor, or through the pause handler registered for that tool.
Package dispatch runs one tool call: through the ToolExecutor, or through the pause handler registered for that tool.
internal/emitter
Package emitter owns the event channel: it filters, counts dropped events, and guarantees the done event is sent once and waited for.
Package emitter owns the event channel: it filters, counts dropped events, and guarantees the done event is sent once and waited for.
internal/engine
Package engine is the loop itself.
Package engine is the loop itself.
internal/modelcall
Package modelcall makes one logical model call: iteration timeout, streaming with an idle-chunk watchdog, and retries on transient failure.
Package modelcall makes one logical model call: iteration timeout, streaming with an idle-chunk watchdog, and retries on transient failure.
internal/state
Package state holds all mutable state of one loop run behind one mutex.
Package state holds all mutable state of one loop run behind one mutex.
internal/stopping_policy
Package stopping holds one stop policy per stop condition.
Package stopping holds one stop policy per stop condition.
internal/transcript
Package transcript logs the conversation as it grows.
Package transcript logs the conversation as it grows.
pause
Package pause is what a pause handler sees and can do.
Package pause is what a pause handler sees and can do.
registry
Package registry tracks running loops by a key of the caller's choice, such as a user or session id, so another goroutine can stop or steer them.
Package registry tracks running loops by a key of the caller's choice, such as a user or session id, so another goroutine can stop or steer them.
Package steerlooptest provides a scripted chat model and a recording tool executor for testing code built on steerloop without calling a provider.
Package steerlooptest provides a scripted chat model and a recording tool executor for testing code built on steerloop without calling a provider.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL