bonnie

package module
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Sep 16, 2026 License: MIT Imports: 27 Imported by: 0

README

BONNIE

BONNIE

Durable agent runs for Go.
Survive a crash. Wait days for a human. Answer over HTTP, Slack, Discord, Telegram, or GitHub.

Go Reference MIT

[!WARNING] Early and experimental. Use at your own risk. BONNIE is pre-1.0 software under active development. The API can change without notice. The durability and the sandbox claims have tests, but no release is proven in production. Do not use a release for work whose loss would hurt. Read Limits before you deploy.


An agent turn usually lives and dies with the process. Stop the process during a tool call and the work is gone. Ask the user a question and you must hold the process open until the user answers.

BONNIE corrects that. It wraps the Kit agent SDK, and a run becomes durable:

run, _ := runner.Start(ctx, "deploy-42", runtime.Input{Text: "Deploy the app."})

if run.State == runtime.RunWaiting {
    fmt.Println(run.Suspend.Prompt) // "Which region?"
    os.Exit(0)                      // ← the process can stop here
}

Come back tomorrow, in a different process, and complete the run:

run, _ := runner.Resume(ctx, "deploy-42",
    []runtime.InputResponse{{Text: "eu-west-1"}})

fmt.Println(run.Response) // "Deployed to eu-west-1."

The agent keeps the full conversation, and the tools it already called. Thus it does not do a side effect a second time.

Contents

Install

As a library:

go get github.com/mark3labs/bonnie

As a CLI:

go install github.com/mark3labs/bonnie/cmd/bonnie@latest

With Nix. This gives you the CLI, and the microsandbox CLI (msb) on its PATH:

nix profile install github:mark3labs/bonnie   # or: nix run github:mark3labs/bonnie

Set a provider key. BONNIE uses the provider that Kit is configured for:

export ANTHROPIC_API_KEY=sk-ant-...   # or OPENAI_API_KEY, or GEMINI_API_KEY

BONNIE also reads a .env in the working directory when one is there, so you can write the key (and any channel credential) into a file instead of exporting it each time. An exported variable still wins over the file, and a missing .env is not an error.

BONNIE needs Go 1.27+ and Linux. The kernel must be 5.13 or newer with Landlock enabled, which is the default on each current distribution. A sandbox is not optional, and the default sandbox needs no installation. Docker or msb give stronger isolation. macOS and Windows are not supported — see Limits.

Development shell

The flake also gives you a shell with Go 1.27, golangci-lint, goreleaser, and the microsandbox CLI:

nix develop
go test -race ./...

The repository has an .envrc, thus direnv allow opens the same shell when you cd into it.

Flake output What it is
packages.default, packages.bonnie the BONNIE CLI
packages.microsandbox the msb CLI and its libkrunfw
apps.msb nix run github:mark3labs/bonnie#msb
overlays.default both packages, for your own nixpkgs

Scaffold an agent

Scaffold an agent, edit one file, then run it.

bonnie init my-agent --model anthropic/claude-sonnet-4-5
cd my-agent
go mod tidy
# edit instructions.md — that file is the agent's system prompt
bonnie dev

bonnie init writes instructions.md (the system prompt), main.go (the one call you own), bonnie_gen.go (the generated wiring), go.mod, and the seed directories skills/ and workspace/. With --tools it also writes a sample tool at tools/echo/tool.go. It never replaces a file: if one file exists, it refuses, names each file it found, and changes nothing.

// main.go — the full default agent
package main

import "github.com/mark3labs/bonnie"

func main() {
	bonnie.New(
		bonnie.WithModel("anthropic/claude-sonnet-4-5"),
	).Serve()
}
# speak to it over HTTP
curl -s localhost:8080/bonnie/v1/runs -d '{"text":"What are you?"}'

# or speak to it in the terminal — one durable conversation, streamed live
bonnie chat --addr 127.0.0.1:8080

Files under workspace/ are copied into each run's sandbox. A file that the model already wrote is never replaced.

Files under skills/ are the agent's skills: one *.md per skill, or one subdirectory per skill with a SKILL.md in it, each with YAML frontmatter that gives a name and a description. Those two fields go in the system prompt; the body arrives only when the model calls activate_skill, so a large skill set costs few tokens until it is used. The tree's skills/ is the whole set — an agent never inherits a skill from a .agents/skills directory it happens to run beside.

There is no configuration file. A setting is a file at a known path (instructions.md, workspace/, skills/, tools/) or an option in main.go. Thus a setting that does not exist is a compile error, and not a key that nothing reads. The built binary accepts two operator flags, -addr and -model. Each flag wins over the related option, thus one binary can change port or model without a new build.

When the agent is ready, bonnie build compiles the tree into one static binary. The binary contains the tools, the instructions, the skills, and the seed files. It serves on a host that has no Go and no BONNIE installation.

Use the library

A durable run in 25 lines. The journal on disk is what makes the run durable.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/mark3labs/bonnie/runtime"
	"github.com/mark3labs/bonnie/sandbox"
	kit "github.com/mark3labs/kit/pkg/kit"
)

func main() {
	// Each message is journalled here before it is kept.
	journal, err := runtime.OpenSQLiteJournal(".bonnie")
	if err != nil {
		log.Fatal(err)
	}
	defer journal.Close()

	// sandbox.Agent gives the model a shell and a filesystem that are not
	// the host's. It also registers the human-in-the-loop tools.
	runner := runtime.NewRunner(journal, sandbox.Agent(
		sandbox.Landlock(),
		kit.WithModel("anthropic/claude-sonnet-4-5"),
	))

	run, err := runner.Start(context.Background(), "run-1",
		runtime.Input{Text: "In one sentence, what is a durable agent run?"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(run.Response)
}

runtime.KitAgent is the unsandboxed seam. It builds a Kit agent whose core tools — shell, read, write, edit — run in your process, with your files and your credentials. bonnie.New() never uses it directly: it wraps it in sandbox.Agent. Call runtime.KitAgent only when your process is already inside isolation that you control. A working directory is not isolation: an absolute path leaves it.

Start the program again with a different message and the same run ID. BONNIE replays the conversation first, thus the agent remembers:

run, _ := runner.Start(ctx, "run-1", runtime.Input{Text: "What did I just ask?"})
// `You asked me "In one sentence, what is a durable agent run?"`

runtime.Input carries more than text:

Field Function
Text the user's message — the one part that becomes conversation history
Files file parts for the turn (kit.LLMFilePart)
Context what the model must know for this turn only; shown before Text, never history
Title names the run in operator listings; recorded on the first turn
Origin where the conversation lives (Channel, Kind); recorded on the first turn

Examine what occurred, with no server:

bonnie runs list --journal .bonnie
bonnie runs show --journal .bonnie run-1
RUN    TITLE              STATE      STEPS  LAST
run-1  What is a durab…   completed  2      You asked me "In one sentence, …"

Park and resume

This is the primary function. An agent asks a question, your process stops, and a new process completes the work.

BONNIE has two tools for this. The model can call them:

Tool Parks the run to...
ask_human ask the operator a question
request_approval get approval before a dangerous action

runtime.KitAgent registers both, thus sandbox.Agent and bonnie.New() register them too.

runner := runtime.NewRunner(journal, sandbox.Agent(
	sandbox.Landlock(),
	kit.WithModel("anthropic/claude-sonnet-4-5"),
	kit.WithSystemPrompt("Before you deploy anything, use ask_human to ask "+
		"which region to deploy to."),
))

run, err := runner.Start(ctx, "deploy-42", runtime.Input{Text: "Deploy the app."})
if err != nil {
	log.Fatal(err)
}

if run.State == runtime.RunWaiting {
	fmt.Println("agent asks:", run.Suspend.Prompt)
	return // no compute is held — the process can stop
}

Later, in any process that can read the same journal:

run, err := runner.Resume(ctx, "deploy-42",
	[]runtime.InputResponse{{Text: "eu-west-1"}})

A parked run holds no compute. To wait one week costs nothing. The examples/github-bot agent does this for real: it asks a question in a comment, the process can stop, and a later delivery resumes the run from the journal.

Your own tools

A BONNIE tool is a Kit tool. Give it to the agent and it joins the sandboxed set and the human-in-the-loop set:

type chargeInput struct {
	Amount int    `json:"amount" description:"Amount in cents."`
	UserID string `json:"user_id" description:"Who to charge."`
}

chargeCard := kit.NewTool("charge_card", "Charge a customer's card.",
	func(ctx context.Context, in chargeInput) (kit.ToolOutput, error) {
		// This runs in YOUR process, with your secrets. The model sees
		// only the result you return.
		if err := stripe.Charge(in.UserID, in.Amount); err != nil {
			return kit.ErrorResult(err.Error()), nil
		}
		return kit.TextResult("Charged."), nil
	})

// In an agent tree:
bonnie.New(bonnie.WithTools(chargeCard)).Serve()

// Or one layer down:
runner := runtime.NewRunner(journal, sandbox.Agent(
	sandbox.Landlock(),
	kit.WithModel("anthropic/claude-sonnet-4-5"),
	kit.WithExtraTools(chargeCard),
))

You can also write a tool that parks the run. ask_human is exactly this:

return kit.ToolOutput{
	Content: "Waiting for the finance team.",
	Halt:    true,
	FinalValue: runtime.SuspendRequest{
		Kind:   "approval",
		Prompt: "Approve a $4,000 refund?",
	},
}, nil

The run stops, Start returns with State == RunWaiting, and run.Suspend.Prompt holds your question.

In an agent tree, bonnie init --tools writes one sample tool. A tool there is tools/<name>/tool.go with func Tool() kit.Tool, and the directory name is the tool's name. bonnie dev and bonnie build generate the wiring again, thus main.go never names a tool.

Sandboxes

Each tool call runs in a sandbox. There is no unsandboxed mode. WithSandbox selects a backend, it does not enable one. If you do not call it, you get sandbox.Landlock(), and not your process.

The default confines tool calls to the run's own workspace with the Linux Landlock LSM, and needs no installation. That is why it is the floor: a default that needs Docker is a default that people switch off.

bonnie.New().Serve() // already sandboxed

Select a stronger backend when the work is not trusted. bonnie init writes both lines as comments, thus the choice is visible:

bonnie.New(
	bonnie.WithSandbox(sandbox.Docker(sandbox.WithDockerImage("python:3.12-slim"))),
	bonnie.WithNetwork(sandbox.NetworkPolicy{Mode: sandbox.NetworkDenyAll}),
).Serve()

If you wire the runner yourself, it is the same provider one layer down:

provider := sandbox.Docker(sandbox.WithDockerImage("python:3.12-slim"))

runner := runtime.NewRunner(journal, sandbox.Agent(provider,
	kit.WithModel("anthropic/claude-sonnet-4-5"),
))

The model gets four tools that run in the sandbox: bash, read_file, write_file, and list_files. Their root is sandbox.Workspace, /workspace. A path that leaves the workspace, also through a symlink the model made, gets sandbox.ErrOutsideWorkspace.

Backend Isolation You install Network policy
sandbox.Landlock()default filesystem containment, shared kernel none: a policy is refused
sandbox.Local() none — development only none: a policy is refused
sandbox.Docker() container namespaces Docker allow-all, deny-all
sandbox.Microsandbox() microVM, guest kernel msb allow-all, deny-all, allow-list

The Docker and microsandbox backends drive a CLI, and Landlock is pure Go. Thus BONNIE stays one static binary.

The default is containment, not isolation. Landlock confines the filesystem and keeps the host environment away from a command. Thus a model cannot read your journal or your API keys. It does not confine the network, and it does not give the command its own kernel. For hostile code, use Docker or microsandbox, and stop egress.

Each Landlock command runs in a child process that re-executes BONNIE's binary, restricts itself, and then becomes the command. The restriction stays after execve and is inherited, thus a subshell cannot escape it.

The microsandbox adapter is verified on Linux with KVM (msb 0.6.18, all 18 conformance cases, network policies enforced with real egress). Its network policy is fixed when the sandbox is made. To attach again with a different policy fails with ErrPolicyMismatch, and does not use the old rules in silence.

Constrain the network. A backend that cannot enforce a policy refuses it with ErrPolicyUnsupported. It never permits everything in silence:

provider := sandbox.Docker()
provider.SetNetworkPolicy(sandbox.NetworkPolicy{Mode: sandbox.NetworkDenyAll})

Select the best backend that is available, with no silent fall-back to no isolation:

provider, err := sandbox.Select(ctx, sandbox.Microsandbox(), sandbox.Docker(), sandbox.Landlock())

sandbox.Seeded(provider, dir) copies a local directory into each sandbox, and never replaces a file that the run already has. An agent tree wraps its workspace/ directory this way.

The sandbox opens at the first tool call that needs it, thus a parked run holds no container. Read the godoc of each provider in package sandbox before you deploy. Each provider states what it contains and what it does not.

HTTP API

bonnie serve --journal .bonnie --model anthropic/claude-sonnet-4-5 --sandbox docker

Or run an agent tree. Its configuration is Go in its own main.go, thus you serve the tree when you run it:

bonnie dev my-agent          # hot reload while you work
bonnie build my-agent        # one static binary, then run it anywhere

Or mount the channel in your own server. A channel gives you its routes, and it is also the inbound surface that a handler resolves an address through:

runner := runtime.NewRunner(journal, sandbox.Agent(provider, opts...))
ch := bonniehttp.New(runner)

mux := http.NewServeMux()
for _, rt := range ch.Routes() {
	handler := rt.Handler
	mux.HandleFunc(rt.Method+" "+rt.Path, func(w http.ResponseWriter, r *http.Request) {
		handler(w, r, ch, nil) // nil: no other channel to hand off to
	})
}
http.ListenAndServe(":8080", mux)

Each route is under /bonnie/v1. The version segment is the wire contract.

Route Function
GET /bonnie/v1/health liveness: {"ok":true,"status":"ready"}, no journal read
GET /bonnie/v1/info agent name, BONNIE version, mounted channels
POST /bonnie/v1/runs start a run, or route to the run that serves an address
GET /bonnie/v1/addresses/{address} report the run that an address resolves to
POST /bonnie/v1/addresses/{address} send to the run that an address resolves to
GET /bonnie/v1/runs/{id} report a run's durable state
POST /bonnie/v1/runs/{id} send a message to one exact run
POST /bonnie/v1/runs/{id}/respond answer a parked run
POST /bonnie/v1/runs/{id}/cancel stop the turn in progress
POST /bonnie/v1/runs/{id}/reset retire the run and free its address
POST /bonnie/v1/runs/{id}/clear drop the conversation, keep the run
POST /bonnie/v1/runs/{id}/compact summarise the older messages now
GET /bonnie/v1/runs/{id}/stream NDJSON event stream, resumable with ?cursor=
# Start a run. It parks on a question.
curl -s localhost:8080/bonnie/v1/runs -d '{"text":"Deploy the app. Ask me the region first."}'
# {"run_id":"run-e8b3fa...","cursor":4,"state":"waiting",
#  "suspend":{"kind":"question","prompt":"Which region?"}}

# Answer it.
curl -s localhost:8080/bonnie/v1/runs/run-e8b3fa.../respond \
  -d '{"responses":[{"text":"eu-west-1"}]}'
# {"run_id":"run-e8b3fa...","state":"completed","response":"Deployed to eu-west-1."}

Each error reply has a message and a stable code: {"error":"...","code":"run_not_found"}. The codes include run_not_found, invalid_run_id, run_not_waiting, run_active, run_retired, unknown_turn_policy, bad_request, too_large, and internal. A client can branch on the code, and not on the text.

POST /bonnie/v1/runs accepts operation_id as an idempotency key, and turn_policy to select what occurs when a turn is already running: steer (the default) injects the message into the turn, queue lets the turn finish first.

Addresses

Chat platforms have threads, not run IDs. Send an address, and BONNIE keeps the mapping in the journal. Thus a restart does not orphan a conversation:

curl -s localhost:8080/bonnie/v1/runs -d '{"address":"session-42","text":"hi"}'

The same address always resolves to the same run. POST /bonnie/v1/runs/{id} is the opposite: it targets one exact run, and returns 404 instead of making a run.

Chat channels

Slack, Discord, Telegram, and GitHub put the same durable runs into a conversation. Mount a channel in main.go, put its credentials in the environment, and run:

bonnie.New(
	bonnie.WithSlack(slack.Config{}),
	bonnie.WithDiscord(discord.Config{}),
	bonnie.WithTelegram(telegram.Config{Username: "mybot"}),
	bonnie.WithGitHub(github.Config{BotName: "my-agent"}),
).Serve()
export SLACK_BOT_TOKEN=xoxb-... SLACK_SIGNING_SECRET=...
export DISCORD_BOT_TOKEN=... DISCORD_PUBLIC_KEY=...
export TELEGRAM_BOT_TOKEN=... TELEGRAM_WEBHOOK_SECRET=...
export GITHUB_APP_ID=... GITHUB_APP_PRIVATE_KEY=... GITHUB_WEBHOOK_SECRET=...
bonnie dev

Credentials come from the environment and never from code. A missing credential is a startup error that names the variable. The GitHub bot name is a setting and not a secret, thus it stays in the config.

Channel Webhook route Verification Address
slack POST /slack/events v0 HMAC signature <channel>/<thread_ts>, or <channel> for a DM
discord POST /discord/interactions Ed25519 signature the channel or thread ID
telegram POST /telegram shared secret header <chat_id>, or <chat_id>/<topic>
github POST /github/events HMAC signature <owner>/<repo>/issues/<n>, or <owner>/<repo>/pulls/<n>/reviews/<id>

An address is channel-local. The framework puts the channel's name in front of it, thus the durable form is slack/C123/1700000000.000900.

Each channel answers in the platform's ACK period while the turn continues. The reply goes back to the thread. A parked run posts its question, and the next message on the thread is the answer. /new in a thread retires the run and starts a new conversation in the same place.

The GitHub channel is a GitHub App. A comment that names @<BotName> on an issue, a pull request, or a review thread starts or continues a run. The channel mints an installation token for each event, uses it only to post back and to read the pull request's changed files, and never lets the token enter the run. A review thread is its own conversation, separate from the pull request's timeline. The hooks OnIssue, OnPullRequest, and OnCheckSuite let the host start a turn from an event with no comment.

While a turn runs, the Slack channel shows what the agent is doing. The mode is slack.Config.Activity: ActivityMessage (the default) edits one placeholder message, ActivityStatus uses Slack's assistant status and needs the assistant:write scope, and ActivityOff shows nothing until the reply.

A mounted channel can also start a conversation on another channel. A route handler gets channel.Outbound beside channel.Inbound:

to, ok := out.To("slack")
if ok {
	err := to.Receive(ctx, "C0123456789", "the nightly digest, please",
		channel.SendOptions{Auth: principal})
}

This is an agent hand-off and not a notification: the text becomes turn input, and the model runs on the destination channel. The destination binds the address before the turn runs, thus a platform event that arrives during the turn continues this run.

The per-platform setup, the dispatch rules, and what is deliberately not implemented are in the godoc of package channel and each adapter below it.

CLI

bonnie init     Scaffold an agent tree
bonnie dev      Run an agent tree with hot reload and the built-in TUI
bonnie build    Compile an agent tree into one static binary
bonnie serve    Mount the HTTP channel and serve durable runs, with no tree
bonnie chat     Interact with an agent over the HTTP channel in a terminal
bonnie runs     List and inspect durable runs
bonnie sandbox  Reclaim the sandboxes of finished runs
bonnie version  Print the BONNIE version
bonnie init my-agent --model anthropic/claude-sonnet-4-5
bonnie init .                      adopt this directory; never replaces a file
bonnie init my-agent --tools       add a sample Go tool

bonnie dev my-agent                hot reload and the terminal interface
bonnie dev my-agent --tui=false    the serve loop alone, for CI
bonnie dev my-agent --dry-run      print the discovery plan, build nothing

bonnie build my-agent              one static binary at ./my-agent
bonnie build my-agent --output bin/agent

bonnie chat --addr 127.0.0.1:8080 --run tui-default

bonnie serve --addr :8080 --journal .bonnie \
             --model anthropic/claude-sonnet-4-5 \
             --sandbox docker --sandbox-deny-network \
             --slack --discord --telegram

bonnie runs list --journal .bonnie --state waiting
bonnie runs show --journal .bonnie run-1
bonnie runs show --journal .bonnie run-1 --json | jq '.[] | select(.kind=="message")'

bonnie sandbox prune --journal .bonnie --sandbox docker --dry-run

--sandbox accepts landlock (the default), docker, microsandbox, local, or auto. none is refused by name, because it was the old default and still lives in scripts.

bonnie serve has no --github flag: the GitHub channel needs a bot name, which is a setting in code and not an environment variable. Mount it from an agent tree with bonnie.WithGitHub.

runs reads the journal directly, thus it works while the server is stopped — which is when you need it.

Journal

The journal is the durability seam. Two implementations are in the box:

runtime.NewMemoryJournal()            // tests and ephemeral runs
runtime.OpenSQLiteJournal(".bonnie")  // SQLite, one database for each run

SQLiteJournal writes <root>/journal.db through a pure-Go driver (modernc.org/sqlite). Thus BONNIE still builds and cross-compiles with CGO_ENABLED=0, and bonnie build still makes one static binary. The database uses WAL mode and fsyncs each commit. runtime.WithFsync(runtime.FsyncRelaxed) trades a bounded loss window for throughput.

One record for each row, thus any SQLite client can read a run:

sqlite3 .bonnie/journal.db \
  "SELECT seq, role, text FROM records WHERE run_id = 'run-1' ORDER BY seq"

What the database gives, against the JSONL files that it replaced:

  • A step is atomic. A tool-calling step is one transaction. Thus the torn single write that a file append permitted is closed, and not only smaller.
  • Concurrent writers are safe, and not refused. SQLite serialises write transactions across processes, and the (run_id, seq) primary key makes a reused sequence number a constraint violation. The per-run lock file, and the ErrRunOwnedElsewhere that it caused, are gone.

Upgrade. A .bonnie directory that still has runs/*.jsonl from an earlier BONNIE is imported at the first open. Records keep their sequence numbers, and each source file is renamed to <run>.jsonl.imported and not deleted. The import is idempotent, thus a crash in the middle costs one more read.

Supply your own journal with seven methods:

type Journal interface {
	Append(ctx context.Context, rec Record) (seq int, err error)
	Replay(ctx context.Context, runID string) ([]Record, error)
	Checkpoint(ctx context.Context, runID string, state RunState) error
	State(ctx context.Context, runID string) (RunState, error)
	Runs(ctx context.Context, state RunState) ([]string, error)
	Persisted() bool
	Close() error
}

The table-driven conformance suite in runtime/journal_conformance_test.go runs against each implementation. Add yours, and it gets the full suite.

Events

Subscribe to a run's events in the process:

events, unsubscribe := runner.Events().Subscribe("run-1", 0)
defer unsubscribe()

for ev := range events {
	fmt.Println(ev.Seq, ev.Type, ev.Text)
}

The types are run_state, run_suspend, run_resume, and run_response, and the live deltas that Kit forwards during a turn.

Or over HTTP, one JSON object for each line:

curl -sN localhost:8080/bonnie/v1/runs/run-1/stream

Each event has a monotonic seq. If a client disconnects, connect again with the last seq it saw, and lose nothing:

curl -sN "localhost:8080/bonnie/v1/runs/run-1/stream?cursor=12"

Durable events are anchored to the journal record that caused them. Thus a cursor keeps its meaning after a restart. Live-only deltas are never replayed.

Session controls

runner.Steer("run-1", "actually, use eu-west-1")   // joins the turn in progress
runner.Cancel("run-1")                             // stops the turn
runner.Clear(ctx, "run-1")                         // forgets the conversation
runner.Compact(ctx, "run-1")                       // summarises the older messages
runner.Retire(ctx, "run-1", "user asked for a new conversation")

To cancel keeps each completed step, thus the run restores to a valid conversation and continues with Start. To retire is permanent: Start and Resume then refuse the run with ErrRunRetired, and the run stays readable.

runner.Snapshot(ctx, runID) reports a run without a turn. runner.IsActive(runID) reports whether a turn runs now.

Run states

pending → running → completed
                  → waiting    (parked for a human; continue with Resume)
                  → cancelled  (stopped by an operator; continue with Start)
                  → failed     (the agent could not finish the turn)
                  → retired    (closed for good; Start and Resume refuse it)

How it works

BONNIE uses four public Kit extension points. There is no fork and no patched SDK:

Need Kit API
Journal each message Options.SessionManager
Checkpoint each step Kit.OnStepFinish
Inject replayed context Kit.OnContextPrepare
Park for a human kit.ToolOutput{Halt, FinalValue}

Three properties are the difference between a demonstration and something you can deploy:

  • Replay is lossless. A journalled message keeps its typed parts, thus a resumed run knows which tools it called and what they returned. It does not do a side effect a second time.
  • A step commits atomically. A tool call and its result reach the journal as one write and one fsync (kit.StepAppender, from Kit v0.106.0). Thus a crash cannot leave a tool call with no answer. If a torn step is on disk — from an older journal, a journal that does not batch, or a short write — restore removes that incomplete step and records the repair.
  • To cancel keeps finished work. A step is written before the context is examined, thus a cancelled turn loses only the step in progress.

The full detail, with the Kit citations, is in the godoc of runtime/. The code is the specification.

Limits

Stated plainly, because the failure modes are not obvious:

  • Linux only. BONNIE's floor is the Landlock LSM, thus a release builds for linux/amd64 and linux/arm64 and nothing else. macOS and Windows are not supported and are not on the roadmap: to restore macOS needs a seatbelt (sandbox-exec) backend of equal strength first, and not one more build target. A kernel older than 5.13, or a kernel booted with Landlock disabled, has no default sandbox. BONNIE refuses to start instead of a run with no confinement — use --sandbox docker there.
  • The default sandbox is containment, not isolation. Landlock confines the filesystem and keeps host credentials away from a command. It does not confine the network, and it shares the host kernel.
  • Do not run BONNIE as a user in the docker group. Landlock mediates the open of a file, and not the connection to a socket. Thus a tool call reaches /var/run/docker.sock when the process can, and that is a full host escape. No path setting closes it. Use an unprivileged user, or microsandbox.
  • Docker is namespaces, not a kernel. Use microsandbox for hostile code.
  • microsandbox is verified on Linux with KVM. Each network policy mode is enforced, but the policy is fixed when the sandbox is made. To attach again with a different policy fails with ErrPolicyMismatch.
  • Sandbox egress is open until you set a policy, and the default backend cannot set one — it refuses the policy instead of ignoring it.
  • The HTTP channel verifies a caller only when you configure one. http.WithAuthenticator (or bonnie.WithHTTPAuthenticator) checks every route but GET /bonnie/v1/health and mints the run's identity from what it proved. Without one the channel carries a Principal it does not examine, so authenticate in front of it, and operation_id is refused because an idempotency key with no proven owner reads another caller's run. The chat channels are different: each one verifies its platform's signature, and a channel with no credentials refuses to serve. That verifies the platform and not the person: a user ID in a verified Slack event is Slack's word.
  • Run ownership is per host, and the journal does not refuse a second writer. SQLite serialises write transactions and rejects a reused sequence number, thus two processes that write one run cannot corrupt it. That is journal integrity and not turn coordination: two servers that both execute the same run still interleave the conversation. SQLite locking also needs POSIX locks that work, thus a journal on a network filesystem is still unsafe.
  • Events are journal-anchored. The stream replays the journal after the in-memory backlog, thus a reconnect — also after a restart — has no gap. Live-only deltas are the exception, and they are marked.
  • Sandbox lifecycle is journalled, and reclamation is manual. bonnie sandbox prune deletes the sandboxes of finished runs. serve does not sweep them yet.
  • The mark3labs modules are publicly fetchable. A scaffolded module runs go mod tidy and resolves bonnie and kit from the proxy; no GOPRIVATE. To author an agent needs Go on your machine. The binary that bonnie build makes needs nothing on the host.

Examples

Example Shows
examples/github-bot a durable agent on GitHub: issues, pull requests, review threads
examples/slack-bot a durable agent in Slack: threads, controls, a live activity indicator
go run ./examples/github-bot

See examples/README.md for commands you can copy.

Documentation

Document Purpose
godoc The specification. Each exported symbol has its contract and, often, the defect that shaped it
CHANGELOG.md What each release changed, and the limits it recorded
docs/RELEASE.md The release checklist, and what each tag confirmed
CONTRIBUTING.md Boundary rule, workspace setup, commands
AGENTS.md The same rules, for a coding agent
SECURITY.md Disclosure, and what BONNIE does not protect you from

Contributing

go build ./...
go test -race ./...
golangci-lint run

Or run the same loop with task check, and CI parity with task ci.

The live-model tests need a build tag and a provider key:

go test -race -tags integration ./runtime ./sandbox

They skip, and never fail, when no key is present. One rule is above the rest: BONNIE uses the public Kit SDK only. See CONTRIBUTING.md.

License

MIT — see LICENSE.

Documentation

Overview

Package bonnie is the entry point of an authored agent tree.

A BONNIE agent is a directory of files whose meaning comes from their paths, and one Go file that starts it:

package main

import "github.com/mark3labs/bonnie"

func main() { bonnie.New().Serve() }

That is the whole default agent. Every slot in the tree has a framework default, and authoring the slot replaces it: instructions.md is the system prompt, workspace/ is the directory the agent's files live in, tools/ holds one directory per tool, and .bonnie holds the journal. Configuration that is not a file is code — an Option on New:

func main() {
	bonnie.New(
		bonnie.WithModel("anthropic/claude-sonnet-4-5"),
		bonnie.WithSandbox(sandbox.Docker()),
	).Serve()
}

There is no manifest file. Data-shaped settings live at their default paths, and everything else is a Go call, so a setting that does not exist is a compile error rather than a key that is accepted and ignored.

The tree's code — its tools — and the copies of its data files that a `bonnie build` binary carries are wired by codegen into bonnie_gen.go, which calls Register from its init. main.go never has to name them.

Index

Constants

View Source
const (
	// DefaultInstructions is the system prompt file, relative to the tree.
	DefaultInstructions = "instructions.md"

	// DefaultWorkspace is the directory the agent's files live in: the
	// working directory of the host's file tools without a sandbox, and the
	// seed mirrored into the sandbox with one.
	DefaultWorkspace = "workspace"

	// DefaultSkills is the tree's skills directory.
	DefaultSkills = "skills"

	// DefaultJournal is the directory the run journal is written to.
	DefaultJournal = ".bonnie"

	// DefaultAddr is the address the HTTP channel binds when none is given.
	DefaultAddr = ":8080"
)

The default layout of a scaffolded tree. These are the paths `bonnie init` writes, the paths codegen embeds, and the paths Agent.Run reads when no option overrides them. They are constants rather than five copies of a string literal, because the scaffold, the generator, the dev loop, and the runtime must agree on the answer: a rule written more than once is a rule one caller can honour while another misses it.

View Source
const DefaultDotenv = ".env"

DefaultDotenv is the environment file Agent.Run loads when it is present in the process's working directory.

Variables

This section is empty.

Functions

func Register

func Register(t Tree)

Register hands the generated wiring to the runtime. The generated bonnie_gen.go calls it from init, before main runs.

It is the seam that keeps main.go short: adding a tool to the tree changes the generated file and nothing the author wrote.

Types

type Agent

type Agent struct {
	// contains filtered or unexported fields
}

Agent is a configured agent: the tree's defaults with the options applied over them. Build one with New, then Agent.Serve it.

Nothing is opened, bound, or read until it serves, so building an agent cannot fail and New returns no error. A setting that cannot apply — a network policy with no sandbox to enforce it, a model beside a host-supplied agent factory — is refused when serving starts, which is the first moment the whole configuration is known.

func New

func New(opts ...Option) *Agent

New builds an agent from the tree's default layout and the options.

func main() { bonnie.New().Serve() }

With no options that is a complete agent: instructions.md is the system prompt, workspace/ is the agent's root for files, .bonnie is the journal, the tools under tools/ are wired by codegen, and the HTTP channel is served on :8080. Each Option replaces one of those.

func (*Agent) Run

func (a *Agent) Run(ctx context.Context) error

Run serves the agent until ctx ends, then drains in-flight turns and returns. It is Agent.Serve without the process: no flags, no signal handler, no exit — for a host that already owns those.

A run that parks holds no compute and lives in the journal, so stopping here is never destructive.

func (*Agent) Serve

func (a *Agent) Serve()

Serve runs the agent until the process is interrupted, then exits.

It owns the process, which is what makes a one-line main possible: it parses the operator flags a serving binary accepts, installs the signal handler, drains in-flight turns on SIGINT or SIGTERM, and exits non-zero after writing the error to stderr. A host that owns its own process calls Agent.Run instead.

The flags are -addr and -model, and each wins over the matching option, so an operator can move a built binary to another port or model without rebuilding it. `bonnie dev` starts a tree's binary with -addr, which is the whole contract between the dev loop and the child.

type Channel

type Channel interface {
	channel.Channel
	channel.Inbound
}

Channel is an inbound transport BONNIE can mount: the HTTP routes it needs and the inbound surface a run resolves through. Every adapter in channel/ is both.

type ChannelFunc

type ChannelFunc func(*runtime.Runner) (Channel, error)

ChannelFunc builds a channel for the runner that serves it. It is called once at start; an error stops the process before it listens.

type Option

type Option func(*config)

Option configures New. This is where a setting that is not a file in the tree lives: the model, a sandbox, an extra channel. A setting that does not exist is a compile error, which is the point.

func Quiet

func Quiet() Option

Quiet suppresses the startup banner.

It no longer has to make an exception for a no-sandbox warning, because no run is unsandboxed: the banner names the backend in force instead, and a host that silences it has still chosen a confined run.

func WithAddr

func WithAddr(addr string) Option

WithAddr binds the HTTP channel to addr instead of DefaultAddr.

func WithAgentFactory

func WithAgentFactory(f runtime.AgentFactory) Option

WithAgentFactory replaces the model-backed agent entirely with one the host builds itself. It is the escape hatch for a program that implements runtime.Agent — a test double, a router, a second framework — and wants BONNIE only for durability and transport.

It cannot be combined with the options that configure the agent BONNIE would have built (WithModel, WithSystemPrompt, WithSandbox, WithSandboxEnv, WithNetwork, WithTools, WithKit): the factory owns the agent, so those settings would be accepted and ignored. Agent.Run refuses instead, naming both.

What the factory owns, it owns completely: the tree's instructions and the tools codegen discovered do not reach it either. They are available through Registered for a host that wants them. The journal, the workspace, the channels, and the shutdown behaviour are unaffected — those are BONNIE's side of the boundary.

func WithChannel

func WithChannel(f ChannelFunc) Option

WithChannel mounts another inbound transport beside the HTTP channel.

func WithDiscord

func WithDiscord(cfg discord.Config) Option

WithDiscord mounts the Discord channel. DISCORD_BOT_TOKEN and DISCORD_PUBLIC_KEY come from the environment; see WithSlack.

func WithGitHub added in v0.5.0

func WithGitHub(cfg github.Config) Option

WithGitHub mounts the GitHub App channel. GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_WEBHOOK_SECRET come from the environment; see WithSlack. GITHUB_INSTALLATION_ID comes from there too, and only a hand-off needs it: a webhook carries its own installation. The bot name is configured, not environmental: it is a setting, not a secret.

func WithHTTPAuthenticator added in v0.7.0

func WithHTTPAuthenticator(fn bonniehttp.Authenticator) Option

WithHTTPAuthenticator verifies who is calling the framework's own HTTP API, and makes the principal it returns the run's identity.

Every chat adapter already does this from a platform signature — Slack's HMAC, Discord's Ed25519, GitHub's HMAC. The HTTP channel carries no such signature, so what counts as proof is the host's to decide: a bearer token, an OIDC assertion, a client certificate, a session cookie.

Without this option the HTTP API authenticates nobody. That is the right default for a loopback `bonnie dev`, and the wrong one for anything reachable by someone else — the routes start runs, read transcripts, and retire conversations. A deployment either passes an authenticator here or puts the process behind something that has already established who is calling.

See github.com/mark3labs/bonnie/channel/http.Authenticator for what a verifier returns, and github.com/mark3labs/bonnie/channel/http.ErrUnauthenticated for how it refuses one.

func WithInstructions

func WithInstructions(path string) Option

WithInstructions reads the system prompt from path instead of DefaultInstructions. An empty path means the agent has no instructions file, which is how a host with no tree runs.

func WithJournal

func WithJournal(dir string) Option

WithJournal writes the run journal to dir instead of DefaultJournal.

func WithKit

func WithKit(opts ...kit.Option) Option

WithKit passes Kit options through to the agent, for settings BONNIE does not name itself.

func WithListener

func WithListener(ln net.Listener) Option

WithListener serves on an already-bound listener instead of dialling the configured address. Tests bind :0 with it and learn the port.

func WithModel

func WithModel(model string) Option

WithModel selects the model, as "provider/name". Without it, Kit's default applies.

func WithName added in v0.5.0

func WithName(name string) Option

WithName names the agent. The name is reported by `GET /bonnie/v1/info` and nowhere else today; it is for a client that talks to several agents.

func WithNetwork

func WithNetwork(p sandbox.NetworkPolicy) Option

WithNetwork constrains what the sandbox may reach. It needs a backend that can enforce it: a policy is refused at startup by a backend that cannot, never stored and ignored. The default Landlock backend confines the filesystem and not the network, so a policy with it is refused and names the backends that can.

func WithSandbox

func WithSandbox(p sandbox.Provider) Option

WithSandbox selects the backend every tool call runs in.

It SELECTS a sandbox, it does not enable one. Leaving it out does not give the model this process's filesystem: a run with no explicit backend gets sandbox.Landlock, which confines tool calls to the run's own workspace using the Linux Landlock LSM and needs nothing installed.

Pass this to choose something stronger — sandbox.Docker for namespaces, sandbox.Microsandbox for a microVM with its own kernel — or to widen the confinement deliberately with sandbox.Local, which provides no isolation at all and is for development only.

The godoc on each provider in package sandbox states what that backend does and does not contain.

func WithSandboxEnv added in v0.7.0

func WithSandboxEnv(env map[string]string) Option

WithSandboxEnv injects environment variables into every command the sandbox runs. It is how a run gets a credential or a setting the model must not choose — a database URL, a registry token, a feature flag — configured out of band so the model never holds the value.

The variables reach every backend, because the injection travels over the same seam a per-command environment does. An injected value wins over a per-command variable of the same name, so a fixed secret cannot be clobbered. Repeated calls merge, and a later key overrides an earlier one. See sandbox.EnvInjected for the full statement.

It does not encrypt the value or hide it from a command the model runs: a command inside the sandbox can print any variable it is given. The isolation is that the model cannot choose what is injected, not that it cannot read it.

func WithShutdownTimeout

func WithShutdownTimeout(d time.Duration) Option

WithShutdownTimeout is how long Agent.Run waits for in-flight turns to reach a checkpoint after a signal. A turn that is cut short still keeps its finished steps — the journal is what survives — but a clean stop is cheaper.

func WithSkills added in v0.7.0

func WithSkills(dir string) Option

WithSkills reads the tree's skills from dir instead of DefaultSkills. An empty dir means the agent loads no skills at all, which is how a host with no tree runs.

A skill is a markdown file with YAML frontmatter: dir holds one *.md or *.txt per skill, or one subdirectory per skill with a SKILL.md in it. Kit scans the directory as given and adds nothing to the path, so BONNIE's skills/ is the whole skill set — the agent never inherits a skill from a .agents/skills it happens to be running beside.

Each skill's name and description reach the system prompt; the body arrives only when the model calls activate_skill. A bundled scripts/, references/, or assets/ file is NAMED in that activation text but lives on the host, outside the sandbox the tools run in, so the model cannot open it. Put what the model must read in the skill body, and put a file it must open in workspace/.

func WithSlack

func WithSlack(cfg slack.Config) Option

WithSlack mounts the Slack channel. Credentials come from the environment and never from code: SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET fill the config's empty fields, and a missing one is a startup error naming the variable. A webhook that does not verify its caller is a door with no lock.

func WithSystemPrompt

func WithSystemPrompt(prompt string) Option

WithSystemPrompt sets the system prompt directly, instead of reading the tree's instructions file. It wins over WithInstructions.

func WithTelegram

func WithTelegram(cfg telegram.Config) Option

WithTelegram mounts the Telegram channel. TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET come from the environment; see WithSlack.

func WithTools

func WithTools(tools ...kit.Tool) Option

WithTools adds tools to the set the model may call, beside the tools codegen discovered under tools/ and Kit's core set.

func WithWorkspace

func WithWorkspace(dir string) Option

WithWorkspace roots the agent's files at dir instead of DefaultWorkspace. An empty dir means no workspace: the process's own directory stays the root, which is how a host with no tree runs.

The workspace is what keeps a model's write off the tree itself — the instructions, the journal, and the source beside them.

type Tree

type Tree struct {
	// Tools are the tools discovered under tools/, one per directory.
	Tools []kit.Tool

	// Instructions is the embedded copy of the tree's instructions file.
	Instructions string

	// Skills is the embedded copy of the tree's skills directory.
	// [Agent.Run] materialises it beside the journal when the tree's skills
	// directory is not on disk, which is what a built binary on a bare host
	// has, and hands the directory to Kit as Options.SkillsDir.
	Skills embed.FS

	// Workspace is the embedded copy of the tree's workspace seed files.
	// [Agent.Run] materialises them beside a built binary that has no tree, and
	// never overwrites a file that is already there.
	Workspace embed.FS
}

Tree is what codegen discovered in an agent tree: the code it wired and the data files it embedded. The generated bonnie_gen.go builds one and hands it to Register from its init, so main.go never names a tool or an embed.

A tree run from its source directory reads its data files from disk and uses the embedded copies only as a fallback. A binary from `bonnie build` has no tree beside it, so the embedded copies are all it has.

func Registered

func Registered() Tree

Registered returns what the generated file registered. It is the zero Tree when a tree has not been generated yet, which is what a hand-written main.go with no tools sees.

Call it at run time — inside main, or later. The generated file registers from init, and Go initialises every package-level variable before it runs any init function, so a package-level `var x = bonnie.Registered()` reads the empty tree.

Directories

Path Synopsis
Package agent implements BONNIE's L2 discovery: the authored agent tree.
Package agent implements BONNIE's L2 discovery: the authored agent tree.
Package channel defines BONNIE's inbound transport abstraction (L3).
Package channel defines BONNIE's inbound transport abstraction (L3).
chat
Package chat holds the plumbing every chat-platform channel shares: the journalled address map, per-run turn locks, the channel.SessionRef implementation, and the dispatch rule that sends a message to the right runner entry point.
Package chat holds the plumbing every chat-platform channel shares: the journalled address map, per-run turn locks, the channel.SessionRef implementation, and the dispatch rule that sends a message to the right runner entry point.
discord
Package discord is BONNIE's Discord inbound transport (L3).
Package discord is BONNIE's Discord inbound transport (L3).
github
Package github is BONNIE's GitHub App channel (L3).
Package github is BONNIE's GitHub App channel (L3).
http
Package http is BONNIE's HTTP inbound transport (L3).
Package http is BONNIE's HTTP inbound transport (L3).
slack
Package slack is BONNIE's Slack inbound transport (L3).
Package slack is BONNIE's Slack inbound transport (L3).
telegram
Package telegram is BONNIE's Telegram inbound transport (L3).
Package telegram is BONNIE's Telegram inbound transport (L3).
Package channeltest is the conformance suite for channel adapters, in the same spirit as the journal and sandbox suites.
Package channeltest is the conformance suite for channel adapters, in the same spirit as the journal and sandbox suites.
Package client is a Go client for BONNIE's HTTP wire API.
Package client is a Go client for BONNIE's HTTP wire API.
cmd
bonnie command
Command bonnie is the BONNIE developer CLI.
Command bonnie is the BONNIE developer CLI.
bonnie/tui
Package tui is the BONNIE terminal user interface.
Package tui is the BONNIE terminal user interface.
examples
github-bot command
Command github-bot is a reference for BONNIE's GitHub channel: the one bonnie.New call that turns a program into an agent which speaks on GitHub.
Command github-bot is a reference for BONNIE's GitHub channel: the one bonnie.New call that turns a program into an agent which speaks on GitHub.
slack-bot command
Command slack-bot is a reference for BONNIE's Slack channel: the one bonnie.New call that turns a program into an agent which speaks in Slack.
Command slack-bot is a reference for BONNIE's Slack channel: the one bonnie.New call that turns a program into an agent which speaks in Slack.
internal
treetest
Package treetest builds throwaway agent trees that compile against this checkout, for tests that need a real `go build` of a scaffolded module.
Package treetest builds throwaway agent trees that compile against this checkout, for tests that need a real `go build` of a scaffolded module.
Package runtime is BONNIE's durable execution layer (L1).
Package runtime is BONNIE's durable execution layer (L1).
Package sandbox gives a BONNIE run an isolated place to run tool calls.
Package sandbox gives a BONNIE run an isolated place to run tool calls.

Jump to

Keyboard shortcuts

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