Skip to content

Commit 05af51f

Browse files
authored
docs: AGENTS.md section on goose2 desktop backend architecture (#8732)
Signed-off-by: Alex Hancock <alexhancock@block.xyz>
1 parent 8f73ef9 commit 05af51f

1 file changed

Lines changed: 53 additions & 8 deletions

File tree

ui/goose2/AGENTS.md

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,61 @@ ThemeProvider manages three axes:
131131
- `tauri-plugin-window-state` persists window size and position.
132132
- Traffic light offset: `pl-20` (80px) to accommodate macOS window controls.
133133

134-
## Backend Architecture
134+
## Architecture
135135

136-
All AI communication goes through **ACP (Agent Client Protocol)**:
137-
- The Tauri app starts a long-lived `goose serve` process and exposes its WebSocket URL.
138-
- The frontend connects directly to `goose serve` over WebSocket and handles ACP notifications in TypeScript.
136+
**All frontend ↔ backend communication in goose2 flows through a single path:**
139137

140-
For non AI communication, such as configuration:
141-
- Use **Tauri commands** (`invoke()` from `@tauri-apps/api/core`) for request/response operations (sessions, personas, skills, projects, etc.).
142-
- Use **Tauri events** (`listen()` from `@tauri-apps/api/event`) for streaming data from ACP.
143-
- Do **not** add HTTP fetch calls to a backend server, `apiFetch` utilities, or sidecar process management.
138+
```
139+
React UI ──► @aaif/goose-sdk (TS) ──► goose-acp (WebSocket, ACP) ──► goose (core)
140+
```
141+
142+
- The Tauri shell spawns a long-lived `goose serve` process and exposes its WebSocket URL via the `get_goose_serve_url` Tauri command. That is essentially the only Tauri command the frontend needs for backend work — it is how the renderer discovers the ACP endpoint.
143+
- The frontend opens a WebSocket to `goose serve` and talks to it using `@aaif/goose-sdk` (published from `ui/sdk/`). The SDK is generated from the ACP custom-method definitions in `crates/goose-sdk/src/custom_requests.rs`, so every backend method has a typed TypeScript client method.
144+
- `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 the `goose` core crate to do the actual work (providers, config, sessions, dictation, etc.).
145+
- `goose` is the pure domain crate. It knows nothing about Tauri or WebSockets — it just exposes Rust APIs that `goose-acp` handlers invoke.
146+
147+
**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.
148+
149+
### The canonical example: skills-as-sources (PR #8675)
150+
151+
The skills → sources migration in [#8675](https://github.com/block/goose/pull/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:
152+
153+
1. **Define the request/response in `crates/goose-sdk/src/custom_requests.rs`.** Use the `JsonRpcRequest` / `JsonRpcResponse` derives 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/import` with paired request/response structs (`CreateSourceRequest` / `CreateSourceResponse`, etc.).
154+
2. **Implement the handler in `crates/goose-acp/src/server.rs`** with `#[custom_method(YourRequest)]`. Keep it thin: unpack the request, call into the `goose` crate, wrap the result. The sources handlers are ~5 lines each — e.g. `on_list_sources` just calls `goose::sources::list_sources(...)` and returns the typed response. Errors map to `sacp::Error::invalid_params()` / `internal_error()`.
155+
3. **Put the real logic in the `goose` crate.** Sources lives in `crates/goose/src/sources.rs` — filesystem CRUD, frontmatter parsing, scope resolution, all of it. `goose-acp` knows nothing about where skills are stored on disk; it just forwards typed arguments. This separation is the point.
156+
4. **Regenerate the SDK.** The TS methods on `GooseClient` are generated into `ui/sdk/src/generated/`. Do not hand-edit generated files.
157+
5. **Call it from the frontend via a feature `api/` module.** See `ui/goose2/src/features/skills/api/skills.ts`. It calls `getClient()` from `acpConnection.ts` and invokes the SDK, then adapts the generic `SourceEntry` shape into a feature-friendly `SkillInfo`:
158+
```ts
159+
export async function listSkills(): Promise<SkillInfo[]> {
160+
const client = await getClient();
161+
const raw = await client.extMethod("_goose/sources/list", { type: "skill" });
162+
const sources = (raw.sources ?? []) as SourceEntry[];
163+
return sources.map(toSkillInfo);
164+
}
165+
```
166+
Feature code (hooks, stores, UI) imports from that `api/` module — it never touches the ACP client directly.
167+
168+
**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.
169+
170+
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](https://github.com/block/goose/pull/8609)) and `ui/goose2/src/shared/api/dictation.ts`.
171+
172+
### When `invoke()` is still appropriate
173+
174+
Tauri commands (`invoke()` from `@tauri-apps/api/core`) are reserved for things that genuinely belong to the desktop shell, not to `goose` core. In practice that means:
175+
176+
- `get_goose_serve_url` — bootstrapping the ACP connection.
177+
- Secret storage owned by the OS keychain (e.g. `save_provider_field`, `delete_provider_config` — note dictation still uses these for writing API keys into the OS keychain, because that's a shell concern).
178+
- Window state, filesystem dialogs, and other Tauri-plugin-backed capabilities.
179+
180+
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.
181+
182+
### Don't
183+
184+
- Don't add HTTP `fetch` calls to a `goose` HTTP API, or reintroduce an `apiFetch` utility. There is no HTTP API for goose2 — the backend is the ACP WebSocket.
185+
- Don't manage a sidecar `goose` process from the renderer. The Tauri shell owns that lifecycle.
186+
- Don't add a new `invoke()` command in `src-tauri/` as a proxy to `goose` core. Add an ACP custom method instead.
187+
- Don't hand-edit `ui/sdk/src/generated/`. Regenerate.
188+
- Don't call the ACP client (`getClient()`) directly from UI components or stores. Go through a `shared/api/*.ts` (or `features/<feature>/api/*.ts`) module so the SDK surface is mockable in tests.
144189

145190
## Tooling
146191

0 commit comments

Comments
 (0)