← Home
teach-me · OpenHands/agent-canvas PR #1561

Learn the cloud proxy split well enough to audit the next one

This lesson teaches the idea behind PR #1561: a cloud request is not just “a cloud request”. It either targets the OpenHands app backend, or it tunnels into a sandbox runtime. The PR makes that distinction visible in code.

Pinned to post-merge head c38fe600. Companion fast visual: show-me page. Prepared by OpenHands (AI) on behalf of the user.

1Background: two cloud worlds share one UI

Skippable beginner layer. If you already know Agent Canvas cloud/runtime architecture, jump to the narrow background below.

Agent Canvas is a browser UI. In local mode it talks to a nearby agent-server. In cloud mode there are two important servers in play:

Cloud/app backend

The durable control plane: organizations, app-conversation records, settings, profiles, automations, and gateway routes. It is reached at backend.host with cloud auth.

Sandbox runtime agent-server

The live execution plane inside a workspace sandbox: terminal output, file bytes, confirmation responses, and endpoints that may exist only while the sandbox is running.

The old helper, callCloudProxy(), served both worlds. With no hostOverride, it called the app backend directly. With hostOverride, it posted an envelope to local /api/cloud-proxy, which then called a sandbox runtime. That branch lives in the old code at origin/main proxy.ts:74-118.

Narrow background: the problem was semantic, not just naming

Both paths were useful, but their shared name made audits hard. A reviewer seeing callCloudProxy(...) could not tell whether the browser was making a normal cloud API call or asking the local agent-server to tunnel into a sandbox. The difference matters for auth, CORS, runtime availability, and future migration work.

2Intuition: label the road, not the destination

Imagine a toy request: “give me events for conversation conv-1.” There are two ways that could work.

App API route

GET /api/v1/conversation/conv-1/events/search

The cloud app backend owns the route and returns persisted or gatewayed events.

Runtime route

GET /api/conversations/conv-1/events/...

The browser cannot call the sandbox host directly, so it asks the local proxy bridge to do it.

The refactor’s core intuition: the helper name should say which road the request travels. If it is an app API route, use callCloudApi(). If it is still runtime-only, make the legacy bridge explicit with callLegacyRuntimeCloudProxy().
Two request lanes after the refactorCloud app API requests go straight to backend.host; runtime-only requests go through local /api/cloud-proxy to the sandbox runtime.cloud serviceapp-owned routecallCloudApi()bearer + X-Org-Idbackend.hostcloud/app backendruntime serviceruntime-only routelegacy proxysession API keysandbox runtimevia /api/cloud-proxy
Fig 1 · Ask: app-owned route or runtime-only route?

3Code walkthrough: what changed, in learning order

Step A — split the helper contract

The new public surface is in src/api/cloud/proxy.ts:55-93.

callCloudApi(req)
  -> axios.request(`${backend.host}${path}`)
  -> cloud auth headers + active X-Org-Id

callLegacyRuntimeCloudProxy(req)
  -> POST `${localAgentServer}/api/cloud-proxy`
  -> envelope includes { host, method, path, headers, body }

The app API helper does not accept a runtime host. The runtime helper requires one. That makes misuse harder to hide in reviews.

Step B — migrate app-owned behavior away from runtime hosts

BehaviorImportant lineWhy it teaches the change
Follow-up messagesconversation-service.api.ts:229-238The browser calls /api/v1/app-conversations/{id}/send-message.
Event count/searchevent-service.api.ts:63-122History and counts are app API reads.
Conversation metricsagent-server-conversation-service.api.ts:563-584Runtime info comes from app-conversation data.
Git changes/diffagent-server-git-service.api.ts:51-105Git panels now use app-conversation gateway routes.

Step C — keep true runtime-only behavior honest

These remain behind callLegacyRuntimeCloudProxy(): confirmation responses event-service.api.ts:34-41, bash output bash-service.api.ts:98-105, command execution and file download agent-server-runtime-service.ts:35-84.

Step D — the main merge joined the same pattern

Cloud profile CRUD uses callCloudApi in profiles-service.api.ts:48-140; running cloud conversation profile switches use /switch_profile through callCloudApi.

Important edge case: this PR is not “delete the runtime bridge”. It is “make the bridge small and named legacy”.

4Quiz: check that the model stuck

Choose an answer. Feedback explains the reasoning.

1. What was the main problem with old callCloudProxy()?

2. Which request belongs on callCloudApi()?

3. Why keep callLegacyRuntimeCloudProxy()?

4. After merging main, how should cloud profile CRUD route?

5. What should reviewers ask for any new cloud call?

Score: 0 / 5 answered