This guide is for Go developers who want to build an agent runtime with ReAgent. After reading it, you should be able to wire a runtime, choose state storage, add tools, connect a model adapter, observe events, and extend the system with your own tool or policy.
The examples use the current local module path reagent. If you consume ReAgent from another module before it is published, add a local replace directive in your go.mod.
A ReAgent application is a small composition of ports:
user input
-> runtime
-> context manager builds model request from state
-> model streams assistant events
-> runtime records assistant message
-> runtime executes tool calls when present
-> runtime records tool results
-> loop continues until no tool calls or all tool results terminate
The runtime owns orchestration. Your application owns the model adapter, tool set, policy, state store, and event handling.
The smallest useful runtime needs a model, a state store, and a context manager.
modelPort := fake.New(fake.Turn{Events: []model.Event{
{Type: model.EventMessageDelta, DeltaText: "done"},
{Type: model.EventMessageEnd},
}})
rt := runtimeport.Runtime{
Model: modelPort,
State: memory.NewStore(),
Context: contextport.NewMessageHistory(),
}Run it with one or more user messages:
err := rt.Run(ctx, runtimeport.RunRequest{
SessionID: "demo",
Input: []agent.Message{{
Role: agent.RoleUser,
Content: []agent.ContentBlock{{
Type: agent.ContentText,
Text: "Say hello.",
}},
}},
}, emit)SessionID selects the durable conversation. If it is empty, the runtime generates one. For resumable applications, pass your own stable session id.
The emitter receives ordered runtime events. A CLI can print deltas, a TUI can render tool state, and a logger can persist structured diagnostics.
emit := func(ctx context.Context, event agent.Event) error {
switch event.Type {
case agent.EventMessageDelta:
fmt.Print(event.DeltaText)
case agent.EventToolStart:
fmt.Fprintf(os.Stderr, "tool started: %s\n", event.ToolCallID)
case agent.EventToolEnd:
if event.ToolResult != nil && event.ToolResult.IsError {
fmt.Fprintf(os.Stderr, "tool failed: %s\n", event.ToolCallID)
}
case agent.EventError:
if event.Error != nil {
fmt.Fprintf(os.Stderr, "%s: %s\n", event.Error.Code, event.Error.Message)
}
}
return nil
}Return an error from the emitter only when event delivery failure should stop the run. This is appropriate when the emitter is also the persistence path or when the UI requires strict backpressure.
Use the in-memory store for tests and short-lived local experiments:
store := memory.NewStore()Use the JSONL store when sessions should survive process restarts:
store, err := jsonl.NewStore(".reagent-state")
if err != nil {
return err
}The JSONL store writes one owner-only directory per session. It keeps an append-only records.jsonl log and an atomic snapshot.json. On load, records are log-authoritative and snapshot metadata is used for fast resume.
To persist emitted events as records, wrap your emitter with the store's recorder:
persistEvents := store.EventRecorder("demo")
emit := func(ctx context.Context, event agent.Event) error {
if err := persistEvents(ctx, event); err != nil {
return err
}
return renderEvent(ctx, event)
}The default context manager is message-history based:
ctxManager := contextport.NewMessageHistory()It reads persisted message records from the current state snapshot and sends those messages to the model. Event records and custom records are not exposed to the model by default. This keeps diagnostic and UI history out of the prompt unless your application intentionally adds it.
Write a custom context manager when you need summarization, custom record interpretation, prompt budgeting, system prompts, or application-specific state injection.
Filesystem tools are created for one workspace root:
fsTools, err := fs.NewTools(".")
if err != nil {
return err
}The bash tool is also rooted at a workspace:
bashTool, err := bash.New(bash.Config{Root: "."})
if err != nil {
return err
}Register tools in the order you want them exposed to the model:
tools := append([]tool.Tool{}, fsTools...)
tools = append(tools, bashTool)
registry, err := tool.NewRegistry(tools...)
if err != nil {
return err
}
rt.Tools = registryThe static registry validates specs, rejects duplicate names, and returns defensive copies of tool specs so later caller mutation cannot change the model-facing catalog.
read
- Requires
path. - Optional
expected_hash,start_line,end_line, andmax_bytes. - Reads files inside the workspace.
- Refuses directory listings.
- Returns content plus metadata such as path, byte size, line count, modification time, hash, and truncation state.
write
- Requires
pathand fullcontent. - Optional
expected_hashandcreate_parents. - Writes atomically.
- Refuses directory writes.
- Preserves existing file permissions where possible.
- Returns old and new hashes when overwriting.
edit
- Requires
pathandmode. - Supports
replace,insert, andpatchmodes. - Uses atomic writes and optional
expected_hashstale-edit protection. replacerequires exactold_textandnew_text.insertrequires one-basedline,positionofbeforeorafter, andtext.patchaccepts unified-diff-style hunks and reports hunk and change counts.
bash
- Requires
command. - Optional
cwd,timeout_ms, andmax_output_bytes. - Runs each command in a separate process.
- Defaults to
bash -lc. - Enforces workspace-contained working directories.
- Times out and terminates the process group on deadline.
- Returns stdout or stderr as text and metadata including exit code, duration, timeout status, and truncation flags.
The OpenAI adapter implements the model port using streaming Chat Completions.
modelPort := openai.New(openai.Config{
Model: os.Getenv("OPENAI_MODEL"),
APIKey: os.Getenv("OPENAI_API_KEY"),
BaseURL: os.Getenv("OPENAI_BASE_URL"),
})BaseURL should be the versioned API root, such as https://api.openai.com/v1. If blank, it defaults to the OpenAI API root. Use another root for compatible gateways or local servers.
The adapter converts ReAgent tool specs into OpenAI function tools and translates streaming provider output into ReAgent model events. It currently supports text and JSON content blocks. File and image blocks are rejected by this adapter until a provider-specific representation is added.
Use policy.AllowAll{} when you want an explicit permissive policy:
rt.Policy = policy.AllowAll{}Implement policy.Policy to deny risky calls or integrate your own approval flow:
type denyShellWrites struct{}
func (denyShellWrites) Decide(ctx context.Context, req policy.Request) (policy.Decision, error) {
if req.ToolCall != nil && req.ToolCall.Name == "bash" {
return policy.Decision{
Action: policy.ActionDeny,
Message: "bash is disabled in this session",
}, nil
}
return policy.Decision{Action: policy.ActionAllow}, nil
}The runtime treats denied calls as model-visible tool errors. ActionAsk is also returned to the model as an unavailable approval unless your host application implements approval outside the runtime call.
A tool has two methods: Spec and Execute.
type echoTool struct{}
func (echoTool) Spec() agent.ToolSpec {
return agent.ToolSpec{
Name: "echo",
Description: "Return the supplied text.",
Parameters: agent.JSONSchema{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string"},
},
"required": []any{"text"},
"additionalProperties": false,
},
ExecutionMode: agent.ExecutionParallel,
SideEffect: agent.SideEffectNone,
}
}
func (echoTool) Execute(ctx context.Context, call agent.ToolCall, snapshot agent.StateSnapshot) (agent.ToolResult, error) {
var args struct {
Text string `json:"text"`
}
if err := json.Unmarshal(call.Arguments, &args); err != nil {
return agent.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
IsError: true,
Content: []agent.ContentBlock{{
Type: agent.ContentText,
Text: "invalid arguments",
}},
}, nil
}
return agent.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: []agent.ContentBlock{{
Type: agent.ContentText,
Text: args.Text,
}},
}, nil
}Register it with the same static registry as built-in tools:
registry, err := tool.NewRegistry(echoTool{})The runtime validates arguments against the tool spec before calling Execute. A custom tool should still decode arguments defensively and return model-visible tool errors for expected input failures. Reserve returned Go errors for runtime-level failures that should stop the run.
The example CLI is useful as an integration smoke test and as a wiring reference.
go run ./cmd/example-agent \
-prompt "Summarize the runtime responsibilities." \
-model "$OPENAI_MODEL" \
-workspace . \
-state .reagent-state \
-session defaultRelevant flags:
-prompt: required user prompt for the next turn.-workspace: workspace root for filesystem and bash tools.-state: durable JSONL state root.-session: durable session id. It must be a single relative path segment.-adapter: currently supportsopenai.-model: model name. Defaults fromOPENAI_MODEL.-base-url: OpenAI-compatible API root. Defaults fromOPENAI_BASE_URL, then to the adapter default.-timeout: optional run timeout such as30sor2m.
The CLI also reads .env as a convenience for local development. It redacts the configured API key from terminal errors.
ReAgent distinguishes model-visible failures from runtime failures.
Return a tool result with IsError: true when the model should see and recover from the problem, such as invalid tool arguments, a missing file, a denied policy decision, a non-zero shell exit, or a command timeout.
Return a Go error when the runtime should fail the run, such as model adapter failure, state persistence failure, emitter failure, or caller cancellation.
Structured errors use agent.AgentError:
return agent.AgentError{
Code: "state_log_corrupt",
Message: "state record log contains invalid JSON",
Retryable: false,
Metadata: map[string]any{"line": 12},
}These errors are suitable for events, records, and UI display.
Use the fake model for deterministic runtime tests. Each call to Stream consumes one scripted turn and records the model request for assertions.
modelPort := fake.New(
fake.Turn{Events: []model.Event{
{Type: model.EventMessageDelta, DeltaText: "first"},
{Type: model.EventMessageEnd},
}},
)
// run runtime...
requests := modelPort.Requests()Use the in-memory store for tests that do not need filesystem durability. Use the JSONL store when you want to verify resume behavior, record ordering, corruption handling, or event recording.
ReAgent is local-agent first. If you expose it to untrusted users or run it against sensitive workspaces, add policy and process boundaries around the runtime:
- choose workspace roots deliberately,
- restrict or omit
bashwhen shell execution is not needed, - require hash guards for high-risk write flows,
- persist event and error records for replay,
- keep secrets out of prompts, tool results, logs, and metadata,
- treat model output as untrusted input to tool validation and policy.
The built-in workspace checks prevent path traversal outside the configured root, but they are not a full sandbox.
- The module path is local until publication.
- The OpenAI adapter targets Chat Completions streaming.
- The adapter does not send file or image content blocks.
- The built-in
readtool does not list directories. - The runtime executes tool calls sequentially even though specs can mark tools as parallel-safe.
- Interactive approvals are a host-application responsibility.
- Planning, MCP, browser automation, vector memory, multi-agent orchestration, and a bundled TUI are intentionally deferred.