ui/desktop/spike-doc/acp-migration-spike.md
ui/desktop currently talks to goosed agent through REST/OpenAPI. We want to migrate the desktop app to talk to Goose through ACP, while keeping the migration gradual and avoiding a large bundle-size increase.
The preferred migration direction is:
goosed during migration to avoid shipping both large binaries./acp inside goosed agent as a temporary bridge.ui/desktop talk directly to /acp for migrated surfaces.goosed and switch desktop to the existing goose serve ACP server.goosed is an Axum HTTP server.
Relevant files:
crates/goose-server/src/main.rscrates/goose-server/src/commands/agent.rscrates/goose-server/src/routes/mod.rsui/desktop/src/goosed.tsui/desktop/src/goosed.ts finds and spawns the bundled goosed binary:
const spawnCommand = goosedPath;
const spawnArgs = ['agent'];
The renderer configures the generated OpenAPI client against the goosed URL in ui/desktop/src/renderer.tsx.
ui/desktop imports generated API methods from ui/desktop/src/api.
Important chat/session paths today:
ui/desktop/src/sessions.ts
startAgent creates sessions.ui/desktop/src/hooks/useChatStream.ts
resumeAgentsessionReplysessionCancelgetSessionupdateFromSessionui/desktop/src/hooks/useSessionEvents.ts
GET /sessions/{id}/events as SSE.The current REST streaming model is Goose-specific:
POST /sessions/{id}/replyGET /sessions/{id}/eventsMessage, Finish, Error, Notification, ActiveRequestsrequest_id / chat_request_idGoose already has ACP support.
Relevant files:
crates/goose/src/acp/server.rscrates/goose/src/acp/transport/mod.rscrates/goose/src/acp/server/custom_dispatch.rscrates/goose-sdk/src/custom_requests.rscrates/goose-cli/src/cli.rsThere are two existing ACP modes:
goose acpRuns ACP over stdio:
Some(Command::Acp { builtins }) => goose::acp::server::run(builtins).await
This is standard ACP, but in Electron it would require the main process to own stdio and bridge to the renderer.
goose serveRuns ACP over HTTP/SSE/WebSocket:
Some(Command::Serve { host, port, builtins }) => handle_serve_command(host, port, builtins).await
The transport router registers:
/health
/status
/acp POST
/acp GET
/acp DELETE
GET /acp upgrades to WebSocket when requested. Otherwise it behaves as SSE. POST /acp accepts JSON-RPC messages.
Local release binary sizes:
target/release/goose 230M
target/release/goosed 218M
Bundling both would add roughly:
goosed + goose = 448M uncompressed
ui/desktop/src/bin currently contains both binaries locally:
ui/desktop/src/bin/goose 230M
ui/desktop/src/bin/goosed 218M
This is too large as a long-term plan.
Do not bundle both goose and goosed for production migration. The temporary bridge is to mount /acp into goosed; the final backend is goose serve.
Migration backend:
goosed agent./acp in the existing goosed Axum app.Migration shape:
ui/desktop renderer
-> http(s)://127.0.0.1:<port>/acp
goosed process
existing REST routes temporarily
ACP /acp
This keeps the backend bundle around current goosed size plus a small ACP wiring delta, instead of adding a second 230M binary.
Final backend:
ui/desktop renderer
-> ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
goose serve
standard ACP methods
_goose/* custom ACP methods
In the final state goosed is not bundled or spawned by the desktop app.
Before deleting the goosed bridge, verify that the final goose serve path has the same
desktop-specific ACP behavior that the bridge depended on during migration. In particular:
goosed startup is either no longer needed or is initialized by
the final desktop goose serve launch pathMounting /acp in goosed should be treated as a bridge, not the destination.
During migration:
ui/desktop
-> REST for unmigrated features
-> /acp for migrated features
bundled backend:
goosed
REST routes
temporary /acp route
After migration:
ui/desktop
-> /acp only
bundled backend:
goose serve
standard ACP
_goose/* custom ACP methods
The migration rule is:
When a feature moves to /acp:
remove its corresponding REST endpoint from goosed
The final cutover from goosed agent to goose serve is blocked until no desktop runtime feature depends on REST/OpenAPI.
Expected effort:
/acp in goosed: relatively easy because both servers are Axum and the ACP router already exists.goosed later: medium effort, because every REST-only desktop capability must first be moved into standard ACP or _goose/* custom ACP methods.goose serveIt is possible to make goose serve also mount goose-server REST routes. That would make goose-cli depend on goose-server.
Tradeoffs:
goose binary packaging.goose binary size.The cleaner migration bridge is the reverse: add ACP to goosed temporarily. The final target remains goose serve, not goosed.
Adding /acp to goosed does not make the current desktop streaming code work unchanged.
The current desktop chat stream expects:
GET /sessions/{id}/eventsMessageEvent objectsActiveRequestsrequest_id / chat_request_idMessage, Finish, Error, NotificationACP streaming is protocol-level:
session/prompt.session/update notifications.Tool approval also changes:
/action-required/tool-confirmation.RequestPermissionRequest.Use WebSocket ACP directly from the renderer.
Preferred shape:
renderer -> wss://127.0.0.1:<port>/acp
send initialize
send session/new
send session/load
send session/prompt
receive session/update notifications continuously
respond to permission requests
WebSocket is preferable to HTTP POST + SSE because it gives one bidirectional connection for requests, responses, notifications, and permission responses. Do not add an Electron-main IPC transport layer for normal ACP chat traffic.
ui/goose2ui/goose2 already has a client pattern that can be reused or adapted.
Relevant files:
ui/goose2/src/shared/api/createWebSocketStream.tsui/goose2/src/shared/api/acpConnection.tsui/goose2/src/shared/api/acpApi.tsui/goose2/src-tauri/src/services/acp/goose_serve.rsui/goose2:
/acp WebSocket URL from TauriGooseClientsessionUpdate notifications through a handlerlistSessionsnewSessionloadSessionpromptcancelSessionsetProvidersetModel_goose/* custom methodsui/desktop can use the same pattern, replacing Tauri URL lookup with Electron URL lookup.
Because ui/goose2 is expected to move out of this repo in the future, desktop should not share runtime code with it. Treat ui/goose2 as a reference implementation and copy/adapt the small ACP client pieces into ui/desktop.
Example URL derivation:
const baseUrl = await window.electron.getGoosedHostPort();
const acpUrl = baseUrl.replace(/^http/, 'ws') + '/acp';
If goosed is running HTTPS, this becomes wss://.../acp.
Current goosed REST uses X-Secret-Key.
Browser WebSocket does not support arbitrary request headers. If /acp is mounted behind the same auth middleware, direct renderer WebSocket may fail.
Chosen direction: /acp should have ACP-compatible token auth.
During migration:
REST routes:
X-Secret-Key header
ACP route:
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
After REST is removed:
ACP only:
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
This preserves a security boundary for the long-term desktop API while still allowing direct renderer WebSocket connections.
Guardrails:
goosed process.X-Secret-Key./acp?token=... URLs.X-Secret-Key only for REST during migration.Alternatives considered:
/acp outside X-Secret-Key auth, relying on localhost binding./acp, for example /acp?token=....Option 2 is the preferred approach. Option 1 matches current ui/goose2 behavior, but it is weaker as the final desktop backend shape.
Keep REST and ACP side-by-side only for feature areas that have not moved yet.
Example shape:
const backend = {
sessions: flags.acpSessions ? acpSessions : restSessions,
chat: flags.acpChat ? acpChat : restChat,
providers: flags.acpProviders ? acpProviders : restProviders,
};
Rules:
goosed remains default for unmigrated surfaces./acp, remove the corresponding REST endpoint from goosed rather than keeping a permanent fallback./acp in goosedAdd ACP Axum router to the goosed agent app.
Likely places:
crates/goose-server/src/commands/agent.rscrates/goose-server/src/routes/mod.rsUse:
goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}
goose::acp::transport::create_router
Need to decide:
Auth model: use token-authenticated /acp for direct renderer WebSocket, separate from REST X-Secret-Key.
Add something like:
ui/desktop/src/acp/createWebSocketStream.ts
ui/desktop/src/acp/acpConnection.ts
ui/desktop/src/acp/acpApi.ts
This can be based on ui/goose2.
Map:
REST /agent/start -> ACP session/new
REST /agent/resume -> ACP session/load
REST /sessions -> ACP session/list
REST /sessions/{id} -> ACP session/load plus replay/session metadata
This proves:
Map:
REST /sessions/{id}/reply
REST /sessions/{id}/events
-> ACP session/prompt + session/update notifications
Prefer moving the chat state toward ACP-native events and data structures rather than preserving the old goosed MessageEvent model. A temporary adapter into existing desktop Message and TokenState shapes is acceptable only as a short bridge if it substantially lowers rollout risk.
Map:
REST /action-required/tool-confirmation
-> ACP RequestPermissionRequest response
Tool display should move toward ACP-native tool_call / tool_call_update state. Any old desktop message-shape adapter should be treated as temporary migration glue.
Use ACP session config options:
setSessionConfigOption({ configId: "provider" })
setSessionConfigOption({ configId: "model" })
setSessionConfigOption({ configId: "mode" })
Use existing Goose custom methods for provider inventory and setup:
_goose/providers/list
_goose/providers/config/read
_goose/providers/config/save
_goose/providers/config/status
_goose/providers/custom/*
_goose/providers/catalog/*
Existing custom ACP methods cover:
_goose/extensions/add
_goose/extensions/remove
_goose/config/extensions
_goose/config/extensions/add
_goose/config/extensions/remove
_goose/config/extensions/toggle
_goose/session/extensions
_goose/tools
_goose/tool/call
_goose/resource/read
_goose/working_dir/update
Existing ACP custom methods cover many settings/product surfaces:
_goose/preferences/*
_goose/defaults/*
_goose/onboarding/import/*
_goose/sources/*
_goose/dictation/*
Before the ACP session-list PR lands, use configured extensions as the first migration slice. This avoids session ID semantics and chat streaming complexity while still proving the core /acp path.
Migrate the read-only configured extensions list behind a feature flag.
Current REST:
GET /config/extensions
Target ACP:
_goose/config/extensions
Do not migrate add/update/delete in this first slice. Those can remain REST until the read path and ACP client plumbing are proven.
/acp._goose/* request dispatch.REST GET /config/extensions filters hidden extensions:
goose::config::get_all_extensions()
.into_iter()
.filter(|ext| !goose::agents::extension_manager::is_hidden_extension(&ext.config.name()))
ACP _goose/config/extensions currently calls crate::config::extensions::get_all_extensions() and does not apply the same hidden-extension filter.
For this spike, either:
Recommendation: fix ACP to match REST. The goal is to prove migration, not introduce UI-visible behavior differences.
Mount /acp in goosed agent.
crates/goose-server./status, /mcp-app-proxy, and /mcp-app-guest routes.Add ACP token auth for /acp.
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
/acp behind REST X-Secret-Key middleware.Fix _goose/config/extensions parity.
warnings.Add ACP client files under ui/desktop/src/acp/.
Suggested files:
ui/desktop/src/acp/createWebSocketStream.ts
ui/desktop/src/acp/acpConnection.ts
ui/desktop/src/acp/acpApi.ts
Base these on ui/goose2, but copy/adapt rather than share.
Add an ACP extensions API wrapper:
getConfigExtensionsViaAcp(): Promise<ExtensionResponse>
It should call:
_goose/config/extensions
and normalize the response to the existing desktop ExtensionResponse shape.
Add a feature flag. Suggested name:
acpConfigExtensions
Route only the read path through ACP when the flag is enabled. Primary integration point:
ui/desktop/src/components/ConfigContext.tsxKeep mutation paths on REST for this first spike:
addExtensionremoveExtensiontoggleExtensionCompare REST and ACP for the same local config:
enabled valuesbuiltin, stdio, streamable_http, etc.)Do not remove GET /config/extensions after this first read-only spike if writes still use REST sync logic that depends on the endpoint.
Remove the REST extension endpoints only after the full extension config surface has migrated:
GET /config/extensions
POST /config/extensions
DELETE /config/extensions/{name}
Corresponding ACP methods:
_goose/config/extensions
_goose/config/extensions/add
_goose/config/extensions/remove
_goose/config/extensions/toggle
Likely REST-only or partially covered areas:
ActiveRequests reattach semanticsDuring the transition these can remain on REST fallback. The final target is to expose each required capability through Goose custom ACP methods under _goose/... unless it maps cleanly to standard ACP.
Short term:
goosed exposes REST + temporary /acp
desktop uses REST by default, ACP by feature flag
Migration:
desktop moves one feature area at a time to ACP
missing backend behavior is added as standard ACP use or _goose custom methods
matching goosed REST endpoints are removed as each feature migrates
End state:
desktop talks to /acp directly
goose serve is the single bundled desktop backend
goosed is no longer bundled or spawned
REST/OpenAPI is removed from desktop runtime behavior
No major architecture decisions remain from this spike. Implementation details still need validation around route merge order, MCP app proxy deduplication, and exact token plumbing.