Baize is a "sidecar" AI assistant runtime: a single process that sits beside services you already run, turns API documentation into tools the assistant can call, and s important writes until a person approves them. Stop it and it leaves almost nothing behind.
One sentence positioning: it's a runtime, not a framework. That positioning drives every architecture decision below.
The core requirement of sidecar deployment is "one process, copy it over, run it." A Go binary needs no interpreter, no dependency installation, no virtualenv on the target machine. For an assistant meant to live in enterprise environments, that's the lowest-cost delivery form.
One assistant process serves multiple entry points at once: the web console, signed alert/ticket ingress, and IM channels. Goroutines make "one process handling many sessions concurrently" straightforward β and parallel tool calls later become almost free.
Enterprise environments run everything: Windows, Linux, macOS, ARM. A single GOOS=linux GOARCH=arm64 go build produces a binary for the target platform, with no toolchain setup on the destination machine.
Tool input schemas come from OpenAPI documents; mapping them onto Go's strong types catches many errors at compile time. For a long-running daemon, that saves a lot of operational pain.
A clean three-layer structure: core loop β tool router β executors.
Users / Channels βββΊ Agent Core Loop (think β pick tool β run β report)
β
βΌ
Tool Router (Registry)
β
ββββββββββββββββββΌβββββββββββββββββ
OpenAPI connector HTTP plugin MCP connector
ββββββββββββββββββΌβββββββββββββββββ
βΌ
Invoker closures (registered in Registry)
β
[ HITL approval gate ]
β
βββββββββββββββ΄ββββββββββββββ
Direct execution HTTP callback executor
(plugin / proxy) (callback to your side)
Core loop (internal/run): LLM thinks β picks a tool β executes β reports. The event stream (llm.thinking, llm.tool_call, tool.result) is persisted, so the console can follow along in real time.
Tool router (internal/tool): a mutex-protected map that registers not functions but "tool contracts + invoker closures".
Executors (internal/connector): tools enter through three sources β OpenAPI docs, HTTP plugins, MCP tool servers β plus a "callback execution" mode that hands execution back to your side.
This is Baize's most important trade-off.
The problem with plugin protocols: in-process plugins (Go plugins, shared libraries, language-binding SDKs) require the plugin to compile with the host process β language, version, and ABI must all align. Most enterprise systems aren't written in Go: legacy systems, Java/.NET/Python services can't load an in-process plugin at all. And even when they could, upgrading a plugin means restarting the process β "sidecar in, clean out" is gone.
What Baize does instead: it doesn't execute the tool itself; it POSTs the invocation to your own endpoint:
{
"tool": "create_ticket",
"arguments": { ... },
"run_id": "run_xxx",
"agent_id": "agent_xxx",
"idempotency_key": "uuid-xxx",
"callback_urls": { "event": "https://your-service/baize-events" }
}
Your side executes it and returns the result. Benefits: language-agnostic, process-isolated, auditable; idempotency_key makes retries safe (no duplicate execution); callback_urls lets your side keep driving follow-up actions.
The cost: one extra network round-trip, the callback endpoint must be reachable, and to prevent forged callbacks you need signed requests (Baize uses callback signing with a TTL against replay).
The registry (tool.Registry) is the core data structure: a sync.RWMutex guarding a map, with runtime register/unregister and per-connector bulk unregister β adding a tool or disabling a connector never requires a restart.
Three tool sources share one registration path:
OpenAPI docs: import Swagger/OpenAPI/Postman-style docs, each operation becomes a tool;
HTTP plugins: a small companion service declares "what tools exist and how to invoke them";
MCP tool servers: connect to external tool ecosystems as an MCP client.
Security policy is baked into each entry at registration time: require_approval (needs a human), require_login (needs a session), security_schemes (which auth scheme to use). Security policy is decided at registration, not asked at execution time β this is the precondition for letting the assistant actually act.
Discovery is trivial: Registry.List() / Registry.Specs() feed the model's tool list, visible live in the console.
Failures are the norm in AI agents, so degradation design matters more than the happy path:
Timeout guardrail: every tool invocation is bound to context.WithTimeout (default 60s, configurable);
Failure is content: Invoker returns (content, isError, err) β err is an infrastructure failure (timeout, network), isError is a business-side failure. Both flow back to the model as structured content, so the model can retry, switch tools, or explain to the user β instead of crashing the whole session;
Approval rejection is not a crash: when a human rejects a write, the run settles into an explicit "rejected" terminal state with a trail, no panic;
Everything is observable: the llm.tool_call β tool.result event stream is persisted, so any problem can be traced step by step;
Context compaction: long sessions get rolling summaries so quality doesn't degrade as the thread grows.
Let's be honest first: Python is the best choice in the AI/Agent ecosystem. LangChain, LlamaIndex and most reference implementations live there. If your goal is fast experimentation and deep reuse of the LLM ecosystem, Python has no rival.
Baize chose Go because its positioning is different:
| Dimension | Go | Python | Node.js |
|---|---|---|---|
| Deployment | Single binary, zero deps | Interpreter + deps/venv | Node runtime + node_modules |
| Resource footprint | Low; one resident process is cheap | Higher; resident processes need care | Medium |
| Concurrency | Native goroutines | GIL-limited; multi-process/async | Event loop |
| Type safety | Static, compile-time checks | Dynamic, found at runtime | Dynamic / TypeScript |
| LLM ecosystem | Newer, catching up fast | Richest | Rich |
| Cross-platform | Cross-compile to all platforms | Needs interpreter on target | Needs Node on target |
The conclusion isn't "Go is better than Python" β it's positioning decides the language:
Goal: framework / fast experimentation β Python;
Goal: sidecar, resident, one-command deployment to enterprise environments, running on modest hardware for a long time β Go's advantages in deployment and resource usage are hard to replace.
All snippets are from the project source, lightly trimmed. Each comes with one line on what problem it solves.
Modeling a "tool" as "a contract the model sees (Spec) + an invoker closure injected by the connector" fully decouples routing from execution:
type Invoker func(ctx context.Context, args map[string]any) (
content map[string]any, isError bool, err error)
type Meta struct {
Spec llm.ToolSpec
ConnectorID string
Method string
Path string
RequireLogin bool
SecuritySchemes []string
}
require_approval / require_login are written into the entry at registration; the tool list is "hot" β adding/removing connectors never requires a restart:
func (r *Registry) RegisterMeta(meta Meta, inv Invoker, requireApproval bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.tools[meta.Spec.Name] = entry{
spec: meta.Spec,
invoker: inv,
requireApproval: requireApproval,
requireLogin: meta.RequireLogin,
connectorID: meta.ConnectorID,
method: meta.Method,
path: meta.Path,
}
}
Hands execution back to your side; the idempotency key makes network retries safe:
payload := map[string]any{
"tool": tool,
"arguments": args,
"run_id": meta.RunID,
"agent_id": meta.AgentID,
"idempotency_key": meta.IdempotencyKey,
}
if strings.TrimSpace(meta.CallbackEventURL) != "" {
payload["callback_urls"] = map[string]any{
"event": meta.CallbackEventURL,
}
}
rawPayload, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(rawPayload))
Non-GET/HEAD/OPTIONS operations are automatically flagged "needs approval" at registration, and only run after a human clicks approve/reject in the console:
needApproval := t.RequireApproval
if ctx.requireApprovalMutating && isMutatingMethod(t.Method) && t.Source == store.ToolSourceSpec {
needApproval = true
}
A timeout guardrail plus "failure is content" semantics keeps one bad tool call from blowing up the whole session:
toolCtx, toolCancel := context.WithTimeout(ctx, e.toolTimeout())
defer toolCancel()
content, isError, invErr := e.Tools.Invoke(toolCtx, payload.ToolName, payload.Arguments)
if invErr != nil {
// Infrastructure failure (timeout/network): persist and close the round
return e.finalizeFailedRun(runID, invErr)
}
// When isError is true, the failure flows back to the model as content;
// the model decides whether to retry or explain.
Baize is still early. The trade-offs above are far from "optimal" β especially the approval UX, channel adapters, and executor extensibility. If you have real-world scenarios, I'd love to hear them.
Repo: https://github.com/rebornace/baize (MIT)
Issues: open a discussion with your scenario β I'll follow up.