show-me · agent-canvas · @ 1499376c

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.

★ One flag, forked everywhere: 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

local vs cloud request routing A conversation call branches on kind: local goes direct to the agent-server via ConversationClient; cloud goes through callCloudProxy, which either calls the cloud app host directly or proxies sandbox hosts through /api/cloud-proxy. Canvas UI conversation-service kind? getActiveBackend local ConversationClient → local agent-server host cloud callCloudProxy app host → direct sandbox host → /api/cloud-proxy local agent-server 127.0.0.1 / configured host cloud API + sandboxes app origin + per-conv runtime
Green = local path, blue = cloud path. Both end at an agent-server; the difference is how you reach it.

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

CallTargetHow
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 hostOverrideThrough /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);
};
CloudLocal
Endpoint/api/v1/sandboxes/{id}/pauseconversation interrupt
Effectpauses the sandbox; current LLM call finishes firstcancels in-flight LLM request immediately
Needsa sandbox_id (throws if missing)conversation url + session key
⚠ So the thread's observation is real and intentional: cloud "pause" is graceful (sandbox-level, waits), local "pause" is really an interrupt (immediate). The button is one UI affordance mapped onto two different backend verbs.

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.

★ The direction raised in the thread — "move cloud support into 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 · 🐾