Showcase · Framework · Go

go-tool-base

The underpinnings for any Go application that ships as a binary: command-line tools to start with, and (as more than half of what is built on it now proves) long-running services and local web UIs too.

v0.49.0, released Pre-1.0

DocsInstallRepositoryGuide

The problem it exists for

Most of a Go application isn’t the part that’s actually yours. It’s configuration that layers files, environment and flags and can tell you where a value came from, an update you can trust not to install somebody else’s binary, errors a user can act on, a clean way to start, run and stop, and the same handful of commands (init, update, doctor, config, docs) written again for every new thing you build.

go-tool-base writes those once. It’s a batteries-included framework (think Rails or Laravel, for a Go binary), and it started out for command-line tools, because everything starts life as a binary you run. But more than half of what’s built on it today doesn’t exit when a command finishes. keryx and krites each serve a local web studio from the same binary, phpbotscout runs as a long-lived bot, and Scout has a window and a voice.

How it works

The usual commands, already written

A tool built on go-tool-base starts from one shared bundle of the things every command needs, called Props (its name and version, a logger, its config, the filesystem, error handling), and adds its own commands, and the framework builds the rest of the tool around them. The built-in commands arrive as features: update, init, MCP, docs, doctor and changelog are on by default, and AI, config, telemetry and man pages are opt-in. A tool switches each one on or off, and the framework behaves accordingly. A tool without an init command, for instance, is allowed to start with no config file at all, because it has no way to create one.

Props: one typed context for what a tool needs

Every command is handed the same thing: Props, a single object holding what a command might need from its surroundings. The name isn’t short for “properties”. A prop is the beam that stops a structure falling down (Props).

In pattern terms, Props is a strictly typed encapsulated context object: one object carrying the services a unit of work needs, passed down instead of a long list of parameters. It’s built by hand in main, so it isn’t an IoC container. Nothing is registered, resolved or built for you, and what goes in is exactly what main put there.

It’s deliberately not Go’s own context.Context. A context carries values as untyped keys, so a command that wants the logger has to look it up, cast it and hope, and a missing one only shows up when that line runs. Props has a typed field for each service, so asking for the logger gets a logger, checked by the compiler, and every dependency a tool has is listed in one struct you can read. In go-tool-base’s own words, “Context is for cancellation and deadlines, not dependency wiring” (architectural decisions), so the two travel side by side and each does one job.

It’s a deliberate “god object”, and a function that takes the whole of it would hide which services it really uses, the way a service locator does. The narrow interfaces are what stop that: a function that only logs asks for something that can log, not for all of Props. rust-tool-base reaches the same outcome with a generic application type and strongly typed config instead (spec 0008).

A filesystem you can swap

Commands never touch the disk directly. They go through a filesystem on Props, which is the real disk in production and an in-memory one under test, and can be layered, for instance a read-only disk with changes held in memory on top. The test helper builds a complete Props with no real filesystem, network, keychain or process exit behind it, so tests that use it are safe to run in parallel.

Assets that merge

A tool ships its defaults, templates and docs inside the binary, so it works the moment it’s installed. Each command registers its own embedded files into the shared asset store, and opening a file reads across all of them. A plain file is taken from whichever command registered last. A structured file (YAML, JSON, TOML, CSV, .env) is merged from every copy, so a feature can add its own settings to the shared defaults without editing anyone else’s (asset management).

Logging and telemetry that are always safe to call

The logger and the log level are on Props, and --debug moves the level for everything at once. The telemetry collector is never empty: when telemetry is off, it’s a stand-in that does nothing, so a command records an event without first checking whether anyone is listening. What that telemetry may collect depends on whose data it is. Usage analytics is “the vendor learning about users and runs on informed consent”, so it’s off until the user opts in. Observability, meaning traces, metrics and logs for a service, is “the operator instrumenting their own service”, so configuring it is consent enough (observability).

The invocation’s own streams

Input, output and whether a person is at the keyboard come from Props too, not straight from the process. No package in the framework names the process’s own standard input or output, and a test enforces that. So a test drives a real setup wizard just by handing it streams, and a tool can redirect its terminal without the framework noticing.

What’s deliberately left out

Props carries no database. go-tool-base’s decision record turns one down because “Different tools need different data stores: imposing a database pattern would pull GTB into application framework territory” (architectural decisions). An event bus, a task queue and a caching layer were turned down for the same reason: the framework is a foundation for tools, not an application framework.

So each tool picks its own storage, and the estate’s default is the user’s own files. keryx keeps its work in files committed to git, and krites keeps its records in files beside the photographs. Where a tool really needs a database, it has the one that suits it: phpbotscout, a bot that runs all the time, uses SQLite, and Scout.DM uses Postgres.

What it costs: a container this broad is easy to lean on for everything, which is why the narrow interfaces exist. And a tool that needs a database wires its own, with nothing from the framework to start from.

A generator that keeps writing the boring bits

The gtb binary is the generator. gtb generate project scaffolds a new tool, gtb generate command adds a command, and add-flag, regenerate, remove, enable and disable keep working on the tool after day one. gtb enable signing, for example, wires in signed self-update with the verification turned on.

Long-running, too

A tool that needs to stay up gets the same supervisor the framework’s service commands use, controls: concurrent startup, health and readiness probes, shutdown in reverse order of registration, and restarts when something falls over. That’s what sits under a studio served from the CLI, or a bot that’s meant to run for weeks, and the config, update and signing story is the same one the short-lived commands get.

Made of modules, not a monolith

Most of what go-tool-base does lives in separate toolkit modules it requires: config, errors, transport, observability, forge for releases and self-update, signing, MCP and chat. go-tool-base is assembled from 45 of them, and the toolkit they come from now holds 93 public modules, nearly all of them split out since the extraction playbook was written in mid-July 2026. You can use any of them without go-tool-base, and go-tool-base is what makes them agree with each other.

Decisions and what they cost

  • Split into modules. The framework was broken out into modules one package at a time, following an extraction playbook (spec 0119), and the generator itself became a nested module of its own (spec 0194). The payoff was measurable: a minimal binary went from 81.3 MB to 34.3 MB, 58% smaller, once the adapters stopped coming along for the ride. What it cost: shared test harnesses get copied into each module, and the generator’s own packages are importable but not supported.
  • Off Viper. Config moved from a Viper wrapper to go/config’s store (spec 0136). What decided it was coherence: reading from a frozen snapshot means a set of related reads never mixes values from before and after a reload, and config’s own test holds that at zero mismatches. A forwarding adapter that would have kept the old API was turned down because it brought the mismatch straight back. What it cost: roughly 350 call sites and 595 lines of documentation, rewritten.
  • An errors package of my own. I’d originally chosen cockroachdb/errors (spec 0001), and later reversed my own decision: the estate used ten of its functions, and paid 46 of go/forge’s 376 transitive packages for them. go/errors replaced it, with hints for the user built in (the reason an alternative without them was passed over). What it cost: one change touching 116 files and 50 sentinel errors.
  • Signed self-update. An update is verified against a signature before it’s installed (spec 0056), using GPG with keys published over WKD, chosen over cosign so verification still works offline and behind a firewall. What it cost: a compromise of both the forge and the key directory at once wouldn’t be detected until a transparency log lands, and that’s still a draft.

Proof in use

  • Seven tools are built on it today, all on the same release: keryx, krites, sigillum, skillup, phpbotscout, colophon and Scout.
  • 66 releases since v0.1.0 in May 2026, and the version in the header above is the current one. A tool’s release merge request runs cicd’s go-core-currency check, which flags a release still built on an older go-tool-base. All seven, and go-tool-base itself, build and publish their binaries through cicd’s goreleaser component.
  • 290 behaviour scenarios across 52 feature files, and 2,954 test and fuzz functions, with a 90% coverage threshold on the pipeline.

Use it when, and when not to

Use it if you’re building a Go application that real people will install and keep updated, whether that’s a command-line tool, something that runs for weeks, or a local web UI served from the same binary, and you’d rather not write config layering, signed self-update, graceful shutdown, a doctor command and an MCP server for the fourth time. Release builds cover Linux, macOS and Windows on amd64 and arm64.

It isn’t a web framework, though. It won’t route your requests or render your pages, and it won’t scaffold a microservice for you: what a long-running process gets from it is the lifecycle around the work, not the work. And it’s overkill if all you need is a command router. It’s also pre-1.0: breaking changes still ship as minor releases and the stability tiers aren’t in force yet, so pin the version.

Where it’s going

The static release channel (spec 0203) has shipped. A tool can find and fetch its own releases from a plain HTTPS location, with no forge in either step, and an updater that needs nothing but HTTPS and its embedded keys is one that can repair a broken install. keryx is the next tool to move onto it. Behind that, update channels and rollback (spec 0104) are drafted, and need another look now that the channel lists every version (go-tool-base#117).

Built on it

The story in posts

All 88 posts →

Last reviewed .