Quickstart
The module needs Go 1.26 and depends on github.com/google/jsonschema-go and github.com/ergochat/readline. The ACP agent in cmd/bluecollar-acp is a second…
Build
go build ./...
go test ./...The module needs Go 1.26 and depends on github.com/google/jsonschema-go and github.com/ergochat/readline. The ACP agent in cmd/bluecollar-acp is a second module, so its protocol dependencies stay out of anything that embeds the loop.
Serve a model locally
OLLAMA_CONTEXT_LENGTH=32768 ollama serve &
ollama pull qwen3.5:4bOllama loads a model with a 4096-token window unless told otherwise. The loop's instructions and a reasoning model's thinking do not fit in that, and the turn fails with finish reason length.
Run the command-line runner
go run ./cmd/bluecollar --model qwen3.5:4b "In one sentence, what is a POSIX user?"cmd/bluecollar talks to any OpenAI-compatible endpoint (--endpoint, default http://127.0.0.1:11434/v1) and prints the ledger to stderr as the turn runs. It registers bash, file_read, write, edit, image_read and plan scoped to --workspace, plus equip when a decision model is configured. With a prompt it runs one turn and exits; with no arguments and a terminal it becomes a conversation.
| flag | effect |
|---|---|
--model | the model to ask, or BLUECOLLAR_MODEL |
--endpoint, --api-key | the chat completions endpoint and its bearer token |
--workspace | the directory shell commands and file tools work in |
--exec-prefix | a wrapper for every shell command, such as docker exec -i <container> |
--without-tools | no tools at all, to watch the loop reason |
--without-intake | skip intake and start a low task directly |
--timeout | how long one turn may run, default five minutes |
--trace | write the run as one file, JSON when the path ends in .json and Markdown otherwise |
--metrics | write what the turn cost as bench.RunMetrics JSON |
--record-tape, --replay-tape | record every model request and answer, or answer from a recording with no endpoint |
Intake needs a decision model, read from BLUECOLLAR_DECISION_ENDPOINT, BLUECOLLAR_DECISION_API_KEY and BLUECOLLAR_DECISION_MODEL. Without one the runner says so on stderr and starts the task anyway. A trace keeps everything the task carried, so read it before sending it anywhere. A tape is for replaying a run that went wrong; it is never evidence that the agent works.
Embed the loop
examples/clock gives the model one tool and asks it the time in Paris.
go run ./examples/clock
# The current time in Paris is Thursday, September 24, 2026 at 2:42 AM.type clockInput struct {
TimeZone string `json:"timeZone"`
}
type clockOutput struct {
Time string `json:"time"`
}
func main() {
ctx := context.Background()
kernel := loop.NewAgentKernel(taskstate.NewTaskRunService(taskstate.NewTaskEventService()), taskstate.NewTaskStepService())
kernel.UseLanguageModelProvider(openaicompatible.NewProvider("http://127.0.0.1:11434/v1", "", "qwen3.5:4b"))
tools := toolcontract.NewToolSet(nil)
toolcontract.RegisterToolFunction(tools, toolcontract.ToolFunction[clockInput, clockOutput]{
Definition: toolcontract.ToolDefinition{
Name: "time_get",
Description: "Get the current date and time in one time zone.",
Visibility: toolcontract.ToolVisibilityModel,
SideEffectClass: toolcontract.ToolSideEffectRead,
InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false,"required":["timeZone"],
"properties":{"timeZone":{"type":"string","description":"an IANA time zone, such as Europe/Paris"}}}`),
ResultContract: &toolcontract.ToolResultContract{Schema: json.RawMessage(`{"type":"object","additionalProperties":false,"required":["time"],
"properties":{"time":{"type":"string"}}}`)},
},
Handler: func(_ context.Context, input clockInput) (clockOutput, error) {
location, errorValue := time.LoadLocation(input.TimeZone)
if errorValue != nil {
return clockOutput{}, errorValue
}
return clockOutput{Time: time.Now().In(location).Format("Monday 2 January 2006, 15:04")}, nil
},
})
startTask := agentcontract.TurnDecision{
Route: agentcontract.TurnRouteStartTask,
Classification: agentcontract.IntakeClassificationBoundedTask,
TaskShape: agentcontract.TaskShapeMaintenanceTask,
TaskLevel: agentcontract.TaskLevelLow,
InitialToolNames: []string{"time_get"},
ExpectedToolCount: agentcontract.ExpectedToolCountOne,
}
result, errorValue := kernel.RunTurn(ctx, agentcontract.AgentTurnRequest{
RequesterPersonID: "person-1",
RequesterName: "Alex",
ConversationID: "conversation-1",
Prompt: "What time is it in Paris right now?",
ToolSet: tools,
PrecomputedTurnDecision: &startTask,
})
if errorValue != nil {
log.Fatal(errorValue)
}
fmt.Println(result.FinishMessage)
}- A tool reaches the model only when its descriptor is
visibleand carries aResultContract, and the result is a JSON object. RunTurnrefuses a turn withoutPrecomputedTurnDecision. The example fills one in by hand, naming the tool the work needs; the next section has intake decide it.- The
taskstateservices keep everything in memory until a host that needs durability gives each one a repository throughUseRepository.
Route, then run
request := agentcontract.AgentTurnRequest{
RequesterPersonID: "person-1",
RequesterName: "Alex",
ConversationID: "conversation-1",
Prompt: "What time is it in Paris right now?",
ToolSet: tools,
}
planner := intake.NewDecisionPlanner(decisions.ConfiguredDecisionModel(os.Stderr), nil, nil)
router := intake.NewTurnRouter(openaicompatible.NewProvider("http://127.0.0.1:11434/v1", "", "qwen3.5:4b"), planner, agentcontract.IntakeOptions{IsEnabled: true})
decision, errorValue := router.Plan(ctx, agentcontract.AgentRequest{
RequesterPersonID: request.RequesterPersonID,
RequesterName: request.RequesterName,
ConversationID: request.ConversationID,
Prompt: request.Prompt,
ToolSet: request.ToolSet,
})
if errorValue != nil {
decision = agentcontract.TurnDecision{
Route: agentcontract.TurnRouteStartTask,
Classification: agentcontract.IntakeClassificationBoundedTask,
TaskShape: agentcontract.TaskShapeMaintenanceTask,
TaskLevel: agentcontract.TaskLevelLow,
InitialToolNames: []string{"time_get"},
}
}
request.PrecomputedTurnDecision = &decision
result, errorValue := kernel.RunTurn(ctx, request)RunTurn fails a turn that arrives without PrecomputedTurnDecision: the host routes before it hands a turn to the harness. result.TaskRun.Status is where the task ended and result.FinishMessage is what the requester reads.
Overview
bluecollar is an embeddable Go agent harness: the loop that takes a request, calls the tools a host hands it, proves completion from its own ledger, and reports failure to the person who asked.
Architecture
A host and a harness compile against one contract package. The host decides who a tool call runs as; the harness decides which call to make.