How the code is organized, and where a change goes. The short, rule-by-rule version for AI assistants is CLAUDE.md; this page is for people.
The big picture#
lazyagents is one Go binary. Without arguments it opens a Bubble Tea v2 TUI; with a subcommand it runs headless. Both are built from the same modules: each module brings its domain logic, its tab, its CLI commands and its doctor checks in a single package.
main.go dispatch only: --version, CLI or TUI
internal/
├── app/ composition root: boot, migrations, THE registry (features.go)
├── feature/ the contract between a module and the root: Feature and Deps
├── core/ paths (XDG), config.yaml — bottom of the stack
├── fsutil/ atomic writes, backups, rotation
├── agent/ one adapter per agent + optional capability interfaces
├── cli/ headless framework: dispatch, help, flags, doctor
├── tui/ TUI framework: root, tabs, help, palette, widgets, themes
└── modules/ one package per module
├── skills/ sessions/ providers/ hooks/ usage/ agents/
└── plugins/ external plugins: process, protocol, proxy tabDependencies only point one way:
fsutil ← core ← agent ← {cli, tui/*} ← feature ← modules/* ← app ← maincli and tui are frameworks and know no module. A module imports them, never the other way around.
Modules#
A module is internal/modules/<name>/:
| File | Contents |
|---|---|
service.go and one file per subject | The domain: Service, types and I/O. Never imports tui/ |
tab.go, view.go, help.go, msgs.go | The tab. Its model is Tab, built by newTab |
cli.go | commands(svc) and checks(svc), unexported |
feature.go | Feature() feature.Feature, the only exported entry point |
A tab file about the same subject as a domain file gets the _ui suffix (install.go / install_ui.go).
The tab never does I/O itself: it calls the service inside a tea.Cmd and gets the result back as a message. Errors become toasts, never panics.
Tabs talk to each other only through messages in internal/tui/events, and those carry aggregates (map[agent]int), never a module’s types.
Adding a module#
- Create
internal/modules/<name>/with the files above. - Register it with one line in
features()ininternal/app/features.go. The order there is the tab order;Last: truesends a read-only tab to the end. - If it needs settings, read its own
config.yamlsection withd.Config.Section("<name>", &cfg). Never add a global key. - If it touches agent files, add a capability (next section).
- Document it:
docs/guide/<name>.md, linked from docs/README.md. A test fails until the guide exists (Documentation).
app/app.go, tui/app.go and cli/cli.go are never edited to add a module.
Agents#
internal/agent is the only package that knows where agents keep things and in which format. Every agent implements Adapter (detect, list sessions, resume, read a transcript, delete a session). Anything more is an optional interface, checked with a type assertion:
| Interface | Used by | What it adds |
|---|---|---|
ProviderHost | providers | apply and clear an endpoint profile in the live config |
HooksHost | hooks | read, add and remove hooks; the events the agent fires |
RateLimitReader | usage | subscription limit windows |
UsageEventReader, UsageReader, AuthModeReader | usage, sessions | tokens per response, per session, and whether the account pays per token |
LiveChecker | sessions | whether a session is running now |
TranscriptProber | sessions | a fast pre-check for full-text search |
Adapter itself does not grow: a new ability is a new interface, implemented only by the agents that support it.
A transcript reaches the reader as []agent.Entry, the same shape for every agent: role (prompt, answer, reasoning, tool call, event), time, and for a call its kind (agent/toolkind.go: shell, edit, read, agent, plan, todo), lines added and removed, whether it failed, and Sub, a transcript of its own (a subagent, another branch) that the same adapter reads as a Session.Path. Each adapter fills what its format records and leaves the rest zero; the reader shows what is there.
Adding an agent#
- Write the adapter in
internal/agent/<agent>.go:Detect(binary inPATHor config dir;--versiononly here), sessions and transcripts. Confirm the formats against the agent’s docs or source; never guess. - Add it to
AllWithIndexininternal/agent/registry.go. The order there is the column order in the TUI. - Implement the capabilities it supports. A new transcript reader that extracts tokens or previews does it in its
lineScanner, through the shared index (internal/agent/index.go), so each file is read once. - Give it a color in the themes (
theme.AgentColor). - Tests use fixtures under a temporary home (
core.PathsIn(t.TempDir())), never the real~. If the CLI can move its files with an environment variable, add it toagent.ConfigOverridesafter checking it against the CLI; a test package that builds adapters callsagenttest.ClearOverrides()in itsTestMain. Pin the CLI’s real formats too: add a recorder for it toscripts/record-fixtures.go, which runs the CLI in a throwaway home against a local fake model, thengo test ./internal/agent -run TestRecordedFixtures -update. See Recorded CLI fixtures. - Run
go test ./docs -update: the agent support reference picks the new agent up. Mention it in the sessions guide if it has sessions.
Recorded CLI fixtures#
Agents change their private session formats between releases without notice. internal/agent/testdata/fixtures/<agent>/<version>/ holds sessions written by the real CLIs, one dir per version, and TestRecordedFixtures runs each adapter over them against a golden.json. The fixture matrix lists every pinned version and where each fixture came from.
go run scripts/record-fixtures.gorecords every CLI installed on the machine: each runs in a throwaway home against fakellm, a deterministic local model server inside the script (OpenAI Chat Completions, OpenAI Responses, Anthropic Messages), with no account and no network. Paths become/work/projand/home/user, system prompts are cut, and a recording that still names the machine (user, hostname, kernel) fails.- A new CLI release gets a new version dir. Keep the old ones: they are the matrix. Then
go test ./internal/agent -run TestRecordedFixtures -updatewrites the goldens; review their diff, since that is where a format change shows. - Codex limits cannot come from a fake model.
-codex-limits-from ~/.codex/sessionscopies therate_limitsshape of the newest real rollout, with every value that could describe the account replaced. - Claude Code limits come from the network.
-claude-limitsmakes the one call the Usage tab makes, with your Claude Code login, throughClaude.RateLimitsitself; it keeps only the fields lazyagents reads (limits,five_hour,seven_day), replaces every value, and the login is never written anywhere. The test serves thatlimits.jsonwith a made-up login.
Rules that protect user data#
These hold everywhere; review checks them.
- Config writes go through
fsutil.WriteAtomic. A live agent file getsfsutil.Backupfirst, and unknown keys are preserved. - JSON agent config is edited through the
settingsprimitive (internal/agent/settings.go), which touches only the target key and keeps the file order. - TOML (Codex) is never reserialized. lazyagents edits only blocks delimited by
# lazyagents — …comments and copies every other line as it is. - Skill activation is a symlink. Deactivating removes the symlink; a real folder is never deleted.
- Secrets live in 0600 files, are masked in the TUI and in
--json, and never reach a log, an error or an exported struct. - No network at boot. Calls happen on demand only.
- Consent is never forged. When an agent asks the user to approve something (Codex’s hook
trusted_hash), lazyagents warns and leaves the approval to the agent. - Plugins are untrusted. Everything they send is sanitized; a failing plugin becomes a dead tab.
The user-facing version of these rules is Safety and data.
Themes#
Colors exist only in internal/tui/theme. UI code uses tokens (theme.Primary, theme.AgentColor(id)) that resolve when rendering, so a style must never be rendered at init. Details: internal/tui/theme/README.md and Themes.
Documentation#
The docs are built to survive change. Anything that is a list in the code is generated from the code; everything else is written by hand and checked by tests.
| Kind | Where | Source of truth | Kept current by |
|---|---|---|---|
| Reference | docs/reference/ | the code: cli.Command Usage/Summary/Help, each tab’s Help() and palette Commands(), the adapters, the theme list | go test ./docs fails when a page is stale; go test ./docs -update rewrites them |
| Guides | docs/guide/<module>.md | written by hand, one per module | a test fails if a registered module has no guide or the index does not link it |
| Topics | docs/*.md | written by hand | the link test |
| Plugin protocol | docs/plugins.md | written by hand | changes only with a protocol version bump |
| Translations | docs/pt-br/<same path> | the English page, translated by hand | the docs workflow warns on the pull request when the English page changed after the translation |
Every relative link and #anchor in every Markdown file of the repository is checked by go test ./docs, so renaming a heading or a file breaks the build instead of the docs.
The docs are also published at rogeriojunior31.github.io/docs/lazyagents. .github/workflows/docs.yml calls a workflow shared by the author’s projects: on a pull request it checks that each page starts with a # H1 (the page title on the site) and that relative links exist; on a v* tag it tells the site to rebuild, so the website always shows the docs of the latest release. Write pages as plain GitHub Markdown: the site uses the first # H1 as the title, docs/README.md as the index and its link order as the sidebar order, and maps relative links to its own pages. docs/dev/ is not published.
Translations live in docs/pt-br/, one file per English page, at the same path. A page that is not translated yet links to the English one (../configuration.md), and each translation keeps the mark of the English version it was made from, right under its title:
# SkillsThe mark is sha256sum docs/guide/skills.md | cut -c1-12. When the English page changes, the docs workflow warns on the pull request and the website shows the Portuguese page as out of date until the translation is reviewed and the mark updated. Only update the mark after reviewing the translation. scripts/check-english.sh skips docs/pt-br/ and the localized metadata in docs/site.json.
Website integration#
docs/site.json owns the project’s display name and summaries per website language. It uses schema: 1, name, and summary with en and pt-br keys. The website reads this metadata from the same imported revision as the Markdown; it does not discover or register arbitrary repositories automatically. A new repository still needs an initial entry and module mount in the website.
requiredTranslations: ["pt-br"] makes complete, current Portuguese documentation mandatory. Every public English Markdown page must have a corresponding translation with the current source hash; missing or stale translations fail the docs tests, shared workflow and website build. Review and translate changes before updating the source mark. Commands, identifiers and literal program messages remain unchanged. Stable section anchors use HTML id anchors so links work in both GitHub and the website.
English pages stay directly under docs/; a Portuguese translation lives at docs/pt-br/<same path>. Use relative links to translated pages when they exist; otherwise link to the English original. The website keeps navigation in the selected language and labels missing or outdated translations. The metadata describes the project, not translation coverage: creating it does not translate any pages. Coverage and current source marks are available at the translation status page.
To repeat this setup in another project: copy the structure of docs/site.json, write docs/README.md, add matching docs/pt-br/ pages with reviewed source marks, and reuse .github/workflows/docs.yml. Validate links, references and metadata here with go test ./docs; the shared workflow validates the metadata, headings, links and translation freshness before notifying the website. Without SITE_DISPATCH_TOKEN, the website checks for released docs in its daily build.
What to update when you change something:
| Change | Update |
|---|---|
| New or changed CLI command or flag | Its Help in cli.go (a test requires one), then go test ./docs -update |
| New or changed key or palette entry | The tab’s Help()/Commands(), then go test ./docs -update |
| New agent, capability or hook event | The adapter, then go test ./docs -update |
| New theme | The YAML, then go test ./docs -update |
| New module | docs/guide/<name>.md and a line in docs/README.md |
| Behavior a user would notice | The guide of that module and CHANGELOG.md |
| New file on disk, network call or write to an agent file | Configuration and Safety and data |
Guides describe tasks and concepts. They name a key or a flag where a task needs it and link to the reference for the full list, instead of copying tables that would go stale.
Checks#
test -z "$(gofmt -l .)" && go vet ./... && go test -race ./... && go build ./...
scripts/check-english.shCI runs the same steps; go test ./... includes the docs checks.