Local vs cloud backends: how a call is routed
Agent Canvas talks to two kinds of backend. Almost every conversation call forks on one boolean — getActiveBackend().backend.kind === "cloud" — into a local ConversationClient or a callCloudProxy. This traces the split end to end, and explains why pause behaves differently on each.
kind === "cloud" is branched at ~15 sites in the conversation service alone. Local → new ConversationClient(...) straight to the agent-server. Cloud → callCloudProxy(...), which itself splits into a direct app-host call and a proxied sandbox call. The pause button's local-vs-cloud difference is one instance of this pattern.1The fork: one backend kind
A backend is just a small record — an id, a host, an api key, and a kind. backend-registry/types.ts:1
export type BackendKind = "local" | "cloud";
export interface Backend { id; name; host; apiKey; kind: BackendKind; }
getActiveBackend() returns the currently selected one, and the conversation service reads .backend.kind before nearly every call to decide how to reach it.
2Two paths out of the app
The shape repeats in method after method. agent-server-conversation-service.api.ts:
if (getActiveBackend().backend.kind === "cloud") {
await callCloudProxy({ backend, method, path, authMode, sessionApiKey });
return;
}
await new ConversationClient(getAgentServerClientOptions()).sendEvent(...);
3Cloud isn't one path — it's two
callCloudProxy makes a second decision based on hostOverride. cloud/proxy.ts:66
| Call | Target | How |
|---|---|---|
| App-host (metadata, search, org) | backend.host (cloud app origin) | Direct axios call with bearer auth |
| Runtime / sandbox (events, files, runtime) | per-conversation sandbox host via hostOverride | Through /api/cloud-proxy with X-Session-API-Key |
ⓘ Why the sandbox detour: per-conversation runtime hosts aren't the configured cloud origin, so they can't be called directly from the browser — they're tunneled through the bundled proxy, which attaches session auth server-side and enforces a host allowlist.
Local, by contrast, has no proxy step. getAgentServerClientOptions resolves one host (the effective local backend or an override) and the client hits it directly. agent-server-client-options.ts:52
4The pause button: pause vs interrupt
This is the thread's question, and it's a clean example of the fork having semantic consequences, not just transport ones. conversation-mutation-utils.ts:41
export const pauseConversation = async (conversationId) => {
const { conversationUrl, sessionApiKey, sandboxId } = await fetchConversationData(conversationId);
if (getActiveBackend().backend.kind === "cloud") {
await pauseCloudSandbox(sandboxId); // POST /api/v1/sandboxes/{id}/pause
return { success: true };
}
// local: interrupt so the in-flight LLM call is cancelled immediately,
// instead of waiting for the current call to finish
return new ConversationClient(...).interruptConversation(conversationId);
};
| Cloud | Local | |
|---|---|---|
| Endpoint | /api/v1/sandboxes/{id}/pause | conversation interrupt |
| Effect | pauses the sandbox; current LLM call finishes first | cancels in-flight LLM request immediately |
| Needs | a sandbox_id (throws if missing) | conversation url + session key |
5Where this hurts (and the direction floated)
The cost isn't any single branch — it's that the kind === "cloud" decision is re-made in ~15 conversation methods plus file upload, secrets, bash, plugins. Local flows go through typescript-client's ConversationClient; cloud flows go through bespoke proxy code. Two transports, two auth models (bearer vs session-api-key), forked per method.
typescript-client" — is exactly the deepening this suggests: put the local-vs-cloud transport behind one client interface so callers stop re-deciding kind at every site. That's a seam decision: one adapter per backend, chosen once, instead of a boolean threaded through every method.Made by smolpaws · /show-me · grounded to OpenHands/agent-canvas @ 1499376c · 🐾