↓ Skip to main content

Architecture

Documented version: v0.5.1

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 tab

Dependencies only point one way:

fsutil ← core ← agent ← {cli, tui/*} ← feature ← modules/* ← app ← main

cli and tui are frameworks and know no module. A module imports them, never the other way around.

Modules
#

A module is internal/modules/<name>/:

FileContents
service.go and one file per subjectThe domain: Service, types and I/O. Never imports tui/
tab.go, view.go, help.go, msgs.goThe tab. Its model is Tab, built by newTab
cli.gocommands(svc) and checks(svc), unexported
feature.goFeature() 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
#

  1. Create internal/modules/<name>/ with the files above.
  2. Register it with one line in features() in internal/app/features.go. The order there is the tab order; Last: true sends a read-only tab to the end.
  3. If it needs settings, read its own config.yaml section with d.Config.Section("<name>", &cfg). Never add a global key.
  4. If it touches agent files, add a capability (next section).
  5. 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:

InterfaceUsed byWhat it adds
ProviderHostprovidersapply and clear an endpoint profile in the live config
HooksHosthooksread, add and remove hooks; the events the agent fires
RateLimitReaderusagesubscription limit windows
UsageEventReader, UsageReader, AuthModeReaderusage, sessionstokens per response, per session, and whether the account pays per token
LiveCheckersessionswhether a session is running now
TranscriptProbersessionsa 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
#

  1. Write the adapter in internal/agent/<agent>.go: Detect (binary in PATH or config dir; --version only here), sessions and transcripts. Confirm the formats against the agent’s docs or source; never guess.
  2. Add it to AllWithIndex in internal/agent/registry.go. The order there is the column order in the TUI.
  3. 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.
  4. Give it a color in the themes (theme.AgentColor).
  5. 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 to agent.ConfigOverrides after checking it against the CLI; a test package that builds adapters calls agenttest.ClearOverrides() in its TestMain. Pin the CLI’s real formats too: add a recorder for it to scripts/record-fixtures.go, which runs the CLI in a throwaway home against a local fake model, then go test ./internal/agent -run TestRecordedFixtures -update. See Recorded CLI fixtures.
  6. 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.go records 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/proj and /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 -update writes 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/sessions copies the rate_limits shape of the newest real rollout, with every value that could describe the account replaced.
  • Claude Code limits come from the network. -claude-limits makes the one call the Usage tab makes, with your Claude Code login, through Claude.RateLimits itself; 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 that limits.json with 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 gets fsutil.Backup first, and unknown keys are preserved.
  • JSON agent config is edited through the settings primitive (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.

KindWhereSource of truthKept current by
Referencedocs/reference/the code: cli.Command Usage/Summary/Help, each tab’s Help() and palette Commands(), the adapters, the theme listgo test ./docs fails when a page is stale; go test ./docs -update rewrites them
Guidesdocs/guide/<module>.mdwritten by hand, one per modulea test fails if a registered module has no guide or the index does not link it
Topicsdocs/*.mdwritten by handthe link test
Plugin protocoldocs/plugins.mdwritten by handchanges only with a protocol version bump
Translationsdocs/pt-br/<same path>the English page, translated by handthe 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:

# Skills

The 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:

ChangeUpdate
New or changed CLI command or flagIts Help in cli.go (a test requires one), then go test ./docs -update
New or changed key or palette entryThe tab’s Help()/Commands(), then go test ./docs -update
New agent, capability or hook eventThe adapter, then go test ./docs -update
New themeThe YAML, then go test ./docs -update
New moduledocs/guide/<name>.md and a line in docs/README.md
Behavior a user would noticeThe guide of that module and CHANGELOG.md
New file on disk, network call or write to an agent fileConfiguration 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.sh

CI runs the same steps; go test ./... includes the docs checks.