22 KiB
AGENTS.md
Guidelines for AI agents (and developers) working on this codebase.
Project Overview
Goose2 is a Tauri 2 + React 19 desktop app. It uses TypeScript strict mode, Vite, and Tailwind CSS 4. The codebase follows a feature-sliced architecture organized under src/app/, src/features/, and src/shared/.
First Steps
Treat this repo as partially Hermit-managed. Do not assume just, pnpm, node, or lefthook are available globally.
- In bash/zsh, run
source ./bin/activate-hermitbefore using repo tools if the shell cannot findjust,pnpm, or other managed binaries. - In fish, run
source ./bin/activate-hermit.fish. - If PATH still looks wrong or you want to avoid shell assumptions, prefer repo-local binaries such as
./bin/just,./bin/pnpm, and./bin/lefthook. - Biome is installed from
package.jsondevDependencies, not from Hermit. Run it throughpnpm,pnpm exec biome, ornpx biomeafterjust setup. - On a fresh clone, a newly created worktree, or after
just clean, runjust setupbefore relying onpnpm, Biome, or app-local tooling. - In new clones and worktrees, ensure git hooks are installed early with
lefthook install. Iflefthookis not on PATH, use./bin/lefthook install. - Agents starting in a fresh clone or worktree should do the setup and hook-install steps proactively rather than assuming the environment is already bootstrapped.
- Use
just devfor the normal desktop workflow. Usejust dev-frontendonly when you intentionally want the Vite app without Tauri.
Common Commands
just setupinstalls frontend dependencies withpnpm installand builds the Rust backend once.just devstarts the desktop app in dev mode and wires Tauri to the local Vite server.just checkruns Biome checks and file-size checks.just testruns the Vitest suite.just tauri-checkrunscargo checkinsrc-tauri.just ciis the main local verification gate.just cleanremoves Rust build artifacts,dist, andnode_modules, sojust setupis required again beforejust dev.
Architecture
The frontend → ACP → goose core path
All frontend ↔ backend communication in goose2 flows through a single path:
React UI ──► features/<feature>/api/ ──► @aaif/goose-sdk (TS) ──► goose-acp (WebSocket, ACP) ──► goose (core)
YOU MUST TREAT THE CLIENT as a THIN CLIENT**
- The Tauri shell spawns a long-lived
goose serveprocess and exposes its WebSocket URL via theget_goose_serve_urlTauri command. That is essentially the only Tauri command the frontend needs for backend work — it is how the renderer discovers the ACP endpoint. - The frontend opens a WebSocket to
goose serveand talks to it using@aaif/goose-sdk(published fromui/sdk/). The SDK is generated from the ACP custom-method definitions incrates/goose-sdk/src/custom_requests.rs, so every backend method has a typed TypeScript client method. - The goose2 TypeScript code should only be UI
goose-acp(crates/goose-acp/src/server.rs) is the server side of the WebSocket. It implements handlers for the custom ACP methods and calls into thegoosecore crate to do the actual work (providers, config, sessions, dictation, etc.).gooseis the pure domain crate. It knows nothing about Tauri or WebSockets — it just exposes Rust APIs thatgoose-acphandlers invoke.
This is the pattern you must follow when adding any new backend-touching feature. When you are vibecoding in this app, it is very tempting to reach for invoke() or add an HTTP fetch — don't. The rule is: if a feature needs to talk to goose core, it goes through the SDK → ACP → goose chain above.
Don't build features entirely in the frontend
If a feature involves data, persistence, secrets, provider config, sessions, filesystem access, network calls to external services, or anything else that could plausibly be reused by the CLI or another goose surface, the logic belongs in the goose core crate, exposed via a typed ACP method. Do not:
- Stand up a feature whose business logic lives in a Zustand store, a React hook, or a
features/<feature>/api/adapter that callslocalStorage,fetch, or filesystem APIs directly. - Reach for
invoke()to add a new Tauri command that proxies intogoose— add an ACP custom method instead. - Do not use
localStorageexcept for things it's absolutely needed for (specifically outlined in other parts of this AGENTS.md) - Duplicate types between TS and Rust — let the SDK generation produce them from
crates/goose-sdk/src/custom_requests.rsand use the generated types ALWAYS. Do not create a shadow of a type you could use from the generated types.
The frontend's job is presentation, navigation, and orchestration of typed SDK calls. If you find a feature growing real logic on the React side, that's a signal to push it down into goose-acp + goose.
File structure
The directory layout is organized to reinforce the path above. Every feature that touches the backend has an api/ module that wraps GooseClient calls — UI and stores never touch the SDK directly.
src/
app/ — App shell, entry point, top-level providers
features/ — Feature modules (see Feature Organization below)
<feature>/
ui/ — React components (required)
hooks/ — Custom React hooks for feature logic (when needed)
stores/ — Zustand state management (frontend-only UI state; not a substitute for goose core)
api/ — Thin wrappers around GooseClient SDK calls (the ONLY place ACP is touched)
types.ts — Feature-specific type definitions (when needed)
shared/
ui/ — Reusable UI components (button, etc.)
lib/ — Utilities (cn.ts for class merging)
theme/ — Theme provider, appearance settings
styles/ — Global CSS, design tokens
hooks/ — Shared hooks
api/ — Shared GooseClient wrappers used by multiple features (e.g. dictation)
constants/ — Shared constants
context/ — Shared contexts
Feature Organization
Not every feature needs every subdirectory. Use only what the feature requires. Note that anything beyond ui/ only — i.e. anything with state or backend calls — should already have a corresponding ACP method on the goose side.
| Pattern | Structure | Examples | Backend shape |
|---|---|---|---|
| Full-featured | stores/ + hooks/ + ui/ + api/ |
agents, chat | Typed ACP methods drive the store |
| Data-driven | stores/ + api/ + ui/ |
projects | CRUD via ACP, store caches results |
| API features | api/ + ui/ |
skills, providers | Pure pass-through to ACP |
| Simple features | ui/ only |
home, settings, sidebar, status | No backend (pure presentation) |
| Tabs | ui/ + types.ts |
tabs | No backend (frontend-only UI state) |
If you're tempted to build a "Full-featured" or "Data-driven" feature without a corresponding api/ module that calls a typed ACP method, stop and add the ACP method first. See "The canonical example" below.
Import Rules for Features
- Shared types live in
src/shared/types/— this is the single source of truth for cross-feature types. - There should be NO root-level
src/stores/orsrc/types/directories. - Feature stores use feature-relative imports (e.g.,
../stores/featureStore). - Cross-feature imports use
@/features/*/stores/or@/shared/types/. - Only
features/<feature>/api/andshared/api/modules may import from@aaif/goose-sdkor callgetClient(). UI components, hooks, and stores must go through those wrappers.
Coding Conventions
- Use
cn()from@/shared/lib/cnfor Tailwind class merging. - Import paths use the
@/alias (maps to./src). - Components are controlled where possible (state lifted to parent).
- Use
@tabler/icons-reactfor icons (transitioning fromlucide-react; existinglucide-reactusage is fine until migrated). - All
<button>elements must havetype="button"to prevent form submission. - Use semantic HTML (
<aside>,<nav>,<header>,<main>).
Localization
- UI copy should go through
react-i18next, not hardcoded English strings, for app areas that are already on i18n. - Shared localization lives in
src/shared/i18n/; useuseTranslation()for text and the helpers insrc/shared/i18n/format.tsfor dates, times, numbers, currency, and relative time. - Keep translations in feature-scoped JSON namespaces under
src/shared/i18n/locales/<locale>/instead of one large file, and use stable keys rather than English sentences as keys. - Do not translate user-authored content, agent/model output, or backend-only strings unless they are rendered directly as Goose UI.
pnpm checkincludescheck:i18n, which flags obvious new raw UI strings in migrated surfaces. Use a narrowi18n-check-ignorecomment only when the string should stay literal.
Theming System
ThemeProvider manages three axes:
| Axis | Values | Persistence | Mechanism |
|---|---|---|---|
| Theme mode | light, dark, system |
localStorage | .dark class on <html> |
| Accent color | Hex color | localStorage | --brand / --color-brand CSS variables |
| Density | compact, comfortable, spacious |
localStorage | --density-spacing CSS variable (0.75/1/1.25) |
- CSS variables are defined in
globals.csswith light/dark variants. - Tailwind config maps CSS variables to semantic color names.
- Color palette tokens:
background(primary/secondary/tertiary),foreground(primary/secondary/tertiary),border,ring, plus semantic variants (info,danger,success,warning).
Component Patterns
- Small, focused components — aim for under 200 lines.
- Props interfaces live in the component file, or in
types.tsfor shared types. - Use
forwardReffor components that need ref forwarding (React 19 makes this optional, but the pattern is still used). - Animations: CSS transitions via Tailwind classes; respect
prefers-reduced-motion. - Entrance animations: use the
isLoadedstate pattern withuseEffect+ short timeout.
Accessibility
- ARIA roles on interactive elements (
role="tab",role="tablist",role="status"). aria-labelon icon-only buttons.aria-hiddenon visually hidden content.aria-selectedon selectable items.- Color-only indicators must have text alternatives.
prefers-reduced-motionis respected globally.
Tauri Integration
- The window starts hidden and is shown via
getCurrentWindow().show()after React mounts. - Use
data-tauri-drag-regionon header areas for window dragging. - Title bar uses
titleBarStyle: "Overlay"withhiddenTitle: truefor a custom titlebar. tauri-plugin-window-statepersists window size and position.- Traffic light offset:
pl-20(80px) to accommodate macOS window controls. - Distro bundle behavior, including feature flags, is documented in
distro/README.md.
Working in the ACP path: examples and rules
The canonical example: skills-as-sources (PR #8675)
The skills → sources migration in #8675 is the clearest illustration of the rule. It deleted 319 lines of Tauri-command code in src-tauri/src/commands/skills.rs and replaced them with ACP custom methods. If you find yourself wanting to add an invoke() command that proxies to goose, that PR is what "doing it the other way" looks like. Copy this shape when adding new endpoints:
- Define the request/response in
crates/goose-sdk/src/custom_requests.rs. Use theJsonRpcRequest/JsonRpcResponsederives and the#[request(method = "_goose/<area>/<action>", response = ...)]attribute. Sources uses namespaced methods like_goose/sources/create,_goose/sources/list,_goose/sources/update,_goose/sources/delete,_goose/sources/export,_goose/sources/importwith paired request/response structs (CreateSourceRequest/CreateSourceResponse, etc.). Keep the docs on those structs aligned with the implementation: today_goose/sources/listis still skill-only; create/import take an explicit target scope (global, plusprojectDirfor project sources), while update/delete/export operate on an existing skill by absolutepath. - Implement the handler in
crates/goose-acp/src/server.rswith#[custom_method(YourRequest)]. Keep it thin: unpack the request, call into thegoosecrate, wrap the result. The sources handlers are ~5 lines each — e.g.on_list_sourcesjust callsgoose::sources::list_sources(...)and returns the typed response. Errors map toagent_client_protocol::Error::invalid_params()/internal_error(). - Put the real logic in the
goosecrate. Sources lives incrates/goose/src/sources.rs— filesystem CRUD, frontmatter parsing, scope resolution, all of it.goose-acpknows nothing about where skills are stored on disk; it just forwards typed arguments. This separation is the point. - Regenerate the SDK. The TS methods on
GooseClientare generated intoui/sdk/src/generated/. Do not hand-edit generated files. - Call it from the frontend via a feature
api/module. Seeui/goose2/src/features/skills/api/skills.ts. It callsgetClient()fromacpConnection.tsand invokes the SDK, then adapts the genericSourceEntryshape into a feature-friendlySkillInfo:Feature code (hooks, stores, UI) imports from thatexport async function listSkills(): Promise<SkillInfo[]> { const client = await getClient(); const raw = await client.extMethod("_goose/sources/list", { type: "skill" }); const sources = (raw.sources ?? []) as SourceEntry[]; return sources.map(toSkillInfo); }api/module — it never touches the ACP client directly.
Note on typed vs untyped calls. Skills currently uses client.extMethod("_goose/sources/...", ...) (the untyped escape hatch) because it reshapes a generic Source API into skill-specific types. The preferred shape for new features is the typed generated methods — client.goose.GooseFooBar({ ... }) — as used by dictation (client.goose.GooseDictationTranscribe) and the provider inventory (client.goose.GooseProvidersList). Reach for extMethod() only when you have a real reason to bypass the generated types.
For a minimal frontend api/ wrapper using the typed shape, see ui/goose2/src/features/providers/api/inventory.ts — ~30 lines, typed SDK calls, thin adapter. For a fully worked end-to-end feature including OS-keychain handling and progress streaming, see the voice dictation feature (#8609) and ui/goose2/src/shared/api/dictation.ts.
Typed ACP config contracts
Build goose2 config flows around typed ACP methods whose contract matches the domain: provider config, preferences, defaults, dictation secrets, or extension config.
Keep raw Goose storage keys and secret-handling decisions behind backend-owned ACP methods. This lets Goose validate inputs, apply provider metadata, invalidate caches, refresh dependent state, and keep generated SDK types aligned with supported behavior.
For reference, define contracts in crates/goose-sdk/src/custom_requests.rs, implement them in crates/goose/src/acp/server/, regenerate ui/sdk/src/generated/, then call the generated client.goose.* methods from a feature or shared api/ wrapper.
When invoke() is still appropriate
Tauri commands (invoke() from @tauri-apps/api/core) are reserved for things that genuinely belong to the desktop shell, not to goose core. Provider config or secret mutations that affect the Goose runtime must flow through React → SDK → ACP → goose core so core can validate provider metadata, invalidate secret caches, refresh inventory, and apply provider changes consistently. In practice, Tauri is limited to:
get_goose_serve_url— bootstrapping the ACP connection.- Native auth subprocesses and desktop-shell side effects.
- Window state, filesystem dialogs, and other Tauri-plugin-backed capabilities.
- Transitional provider cleanup such as
delete_provider_configwhile local OAuth/cache side effects still live in the shell.get_provider_config,check_all_provider_status, and provider deletion duplicate provider/config knowledge in Tauri today and should move behind provider-scoped ACP methods.
The long-term provider-config API should be provider-scoped rather than a frontend composition of generic config/secret writes. Prefer methods such as _goose/providers/config/read, _goose/providers/config/status, _goose/providers/config/save, and _goose/providers/config/delete; inventory refresh can be part of the result, but active provider reload belongs in the config mutation/apply path, not in _goose/providers/inventory/refresh.
If the thing you're building is "get data from goose" or "tell goose to do something," it is not one of these cases. Add a custom ACP method instead.
Don't
- Don't add HTTP
fetchcalls to agooseHTTP API, or reintroduce anapiFetchutility. There is no HTTP API for goose2 — the backend is the ACP WebSocket. - Don't manage a sidecar
gooseprocess from the renderer. The Tauri shell owns that lifecycle. - Don't add a new
invoke()command insrc-tauri/as a proxy togoosecore. Add an ACP custom method instead. - Don't hand-edit
ui/sdk/src/generated/. Regenerate. - Don't call the ACP client (
getClient()) directly from UI components or stores. Go through ashared/api/*.ts(orfeatures/<feature>/api/*.ts) module so the SDK surface is mockable in tests.
Tooling
| Tool | Purpose |
|---|---|
| Hermit | Manages repo binaries such as node, pnpm, just, and lefthook |
| Just | Task runner (just dev, just build, just check) |
| Lefthook | Git hooks (pre-commit, pre-push) |
| Biome | Linting and formatting |
| pnpm | Package manager |
Additional tooling notes:
- Prefer repo-managed binaries over global tools when there is any ambiguity about PATH.
- Hermit manages
node,pnpm,just, andlefthook, while Biome comes fromnode_modulesafterjust setup. - Tauri backend commands still rely on a working Rust/Cargo toolchain.
- Pre-commit hooks run formatting plus
just check. - Pre-push hooks run
just fmt-check,just clippy,just check,just test,just build, andjust tauri-check. - Do not use
--no-verifyto bypass hooks. Fix the underlying issue instead.
Performance Logging
- Frontend perf logs use
perfLog()from@/shared/lib/perfLog. Messages are tagged[perf:<channel>](startup, conn, load, newtab, prepare, send, api, stream, replay, chatview). Enabled automatically in Vite dev mode, or opt-in vialocalStorage.setItem("goose.perf", "1")in a release build. - Backend perf logs live in
crates/goose-acp/src/server.rsundertarget: "perf"atdebug!level. Off by default; enable withRUST_LOG=perf=debug,infoon thegoose serveprocess. just devandjust dev-debugexportRUST_LOG=perf=debug,infoso the childgoose serveemits perf logs without extra setup. Override by settingRUST_LOGin the environment before invokingjust.
Testing & Verification
- Unit/component tests use Vitest and Testing Library via
just testorpnpm test. - E2E tests use Playwright via
just test-e2eandjust test-e2e-all. - Before handing off a change, run the smallest relevant verification step. Use
just ciwhen you need the full local gate. - GitHub Actions also runs desktop-oriented checks, including Playwright coverage, that are broader than the local pre-push hook.
Key Dependencies
react19.1,react-dom19.1@tauri-apps/api2.x@tanstack/react-query5.xtailwindcss3.x withtailwindcss-animate@tabler/icons-reactfor icons (migrating fromlucide-react)class-variance-authorityfor component variantsclsx+tailwind-mergefor class merging@radix-ui/react-slotfor polymorphic components
Don'ts
- Don't import from
../across feature boundaries — use@/paths. - Don't put business logic in UI components — extract to hooks or utilities.
- Don't use inline styles except for dynamic values (like animation delays).
- Don't add dependencies without checking if an existing one covers the need.
- Don't skip
type="button"on buttons. - Don't use color-only indicators without text alternatives.
- Never use
--no-verifywhen pushing — fix the underlying lint/hook issues. - Don't create root-level
src/types/orsrc/stores/directories — types belong insrc/shared/types/, stores belong insrc/features/<feature>/stores/. - Don't duplicate type definitions across files — each type has one canonical location.
- Don't build features end-to-end in the frontend. If it talks to data, secrets, providers, sessions, or the filesystem, it goes through
goosecore via a typed ACP method. See Architecture → Don't build features entirely in the frontend.