Webview Bridge: the gRPC-over-postMessage protocol
Responsibilities
The webview is a React app running inside an isolated iframe sandbox, and the only way it can talk to the extension host is through webview.postMessage / webview.onDidReceiveMessage. Cline builds a "gRPC-over-postMessage" protocol on top of this bare API: the webview wraps each call as a GrpcRequest (service + method + message + request_id), the extension side dispatches it by service/method to a concrete handler, and the handler's return value is wrapped as a grpc_response and pushed back. This protocol lands between apps/vscode/src/hosts/vscode/VscodeWebviewProvider.ts and apps/vscode/src/core/controller/grpc-handler.ts.
What it does can be split into three layers. The bottom layer is webview HTML injection and CSP: in production Cline uses the bundled index.js + index.css; in dev mode it connects to a local Vite dev server for HMR. The middle layer is message send/receive: setWebviewMessageListener registers the onDidReceiveMessage callback, and postMessageToWebview pushes an ExtensionMessage back to the webview. The top layer is protocol dispatch: messages from the webview fall into only two types (grpc_request and grpc_request_cancel); every concrete business operation (askResponse, showTaskWithId, subscribeToState, etc.) is modeled as a gRPC service.method call and routed by the serviceHandlers registry to a specific handler.
Design motivation
- A unified protocol instead of switch-case: old Cline dispatched with a giant
type: "askResponse" | "showTaskWithId" | ...switch, and adding a new webview call meant touching theExtensionMessagetype + the switch + a handler. Switching to gRPC service/method (GrpcRequest:7) means adding a call only requires registering a service.method handler in generated code; the webview side calls it through a typed client, and types on both ends stay aligned. - request_id correlates request and response:
GrpcRequest.request_idis a string (request_id:11). The webview uses it to find the pending Promise; the extension uses it for cancellation and recording.GrpcResponse.request_idis echoed back unchanged, and the webview dispatches by ID on receipt. - streaming via is_streaming + sequence_number:
handleStreamingRequest:113creates aresponseStreamcallback the handler can invoke repeatedly to push partial results, each carryingis_streaming: trueand an increasingsequence_number; the final call setsisLast=trueto close the stream. This suits scenarios like "task state keeps updating" or "mcp server list as a stream." - Recording middleware:
withRecordingMiddlewarewrapspostMessage(withRecordingMiddleware:23). Everygrpc_responseis handed toGrpcRecorderBuilder.recordResponse, so during debugging the full request-response sequence can be replayed. - CSP strict but not blocking HMR: production CSP uses a nonce +
script-src 'nonce-${nonce}' 'unsafe-eval'; connect-src only allows posthog and cline.bot (CSP meta:113). Dev mode CSP opens up localhost and ws so Vite HMR works (dev CSP:200). - ExtensionMessage is down to just grpc_response: the
ExtensionMessageinterface now has onlytype: "grpc_response"(ExtensionMessage:21). Every payload the extension pushes to the webview (state updates, partial messages, ask/say) is wrapped inside some grpc_response.message, following the "extension proactively invokes a streaming handler to push" model — there is no longer a standaloneplusButtonClickedmessage type.
Key files
resolveWebviewView:53— called the first time VSCode opens the sidebar; injects HTML, registers the message listener, and binds visibility/destroy callbacks.html injection:62— picksgetHMRHtmlContentorgetHtmlContentbased on dev/production.setWebviewMessageListener:145— registersonDidReceiveMessageand forwards the message tohandleWebviewMessage.handleWebviewMessage:161— entry switch; handles onlygrpc_requestandgrpc_request_cancel.postMessageToWebview:189— the single exit point for the extension to push messages to the webview; callswebview.postMessagedirectly.handleGrpcRequest:53— main entry; first recordRequest, then branch onis_streaminginto unary or streaming handling.handleUnaryRequest:75— unary mode: calls the handler to get a response, wraps it asgrpc_response, pushes it back.handleStreamingRequest:113— streaming mode: creates aresponseStreamcallback the handler invokes repeatedly to push partials.handleGrpcRequestCancel:163— cancels a request by looking up the cleanup function inGrpcRequestRegistryand invoking it.getHandler:192— looks up the handler byserviceName.methodNamefrom theserviceHandlersregistry; throws if not found.ServiceRegistry:30— service registry; each service registers a set of methods, marked unary or streaming.GrpcRequest:7— the webview→extension request structure, carrying service/method/message/request_id/is_streaming.ExtensionMessage:21— the extension→webview message; onlytype: "grpc_response"remains.
Data flow
When the webview sends a request, the client first wraps it as a GrpcRequest with a typed client and calls postMessage; on the extension side onDidReceiveMessage receives it and routes it through handleWebviewMessage:
// apps/vscode/src/hosts/vscode/VscodeWebviewProvider.ts
async handleWebviewMessage(message: WebviewMessage) {
const postMessageToWebview = (response: ExtensionMessage) => this.postMessageToWebview(response)
switch (message.type) {
case "grpc_request": {
if (message.grpc_request) {
await handleGrpcRequest(this.controller, postMessageToWebview, message.grpc_request)
}
break
}
case "grpc_request_cancel": {
if (message.grpc_request_cancel) {
await handleGrpcRequestCancel(postMessageToWebview, message.grpc_request_cancel)
}
break
}
default: {
Logger.error("Received unhandled WebviewMessage type:", JSON.stringify(message))
}
}
}Once handleGrpcRequest has the GrpcRequest, it first records it via recordRequest, then wraps postMessageToWebview with withRecordingMiddleware so responses also go through recording, and finally branches on request.is_streaming (is_streaming branch:63). The unary path calls the handler once and wraps the result as grpc_response.message to push back (unary response:86); the streaming path hands a responseStream callback to the handler, and each invocation pushes a grpc_response carrying is_streaming: !isLast and a sequence_number. On the webview side, responses are dispatched by request_id to the matching pending Promise, and streaming messages are merged in sequence order. The concrete askResponse business is the webview calling the AskResponse method of the cline.askResponse service; the handler at askResponse handler:14 maps the response to a ClineAskResponse and calls task.handleWebviewAskResponse, which wakes up the pWaitFor inside the Task.
Boundaries and failure modes
- Unrecognized message types: the default branch of
handleWebviewMessageonly logs an error and does not throw (default error:177). An older cached webview might still be sending the legacyplusButtonClickedtype; the extension tolerates it. - Handler throws: when a unary handler throws, the catch wraps
error.messageasgrpc_response.errorand pushes it back (unary error:96). When a streaming handler throws, the catch pushes anis_streaming: falseerror response to close the stream (streaming error:147). - Unknown service/method: when
getHandlercannot find the service or method, it throwsUnknown service: ${serviceName}(unknown service:196); this error is caught upstream and wrapped as grpc_response.error. - Streaming does not close on its own: when a handler finishes normally, the code comment explicitly says "do not send a final message, the stream stays open" (
keep stream open:142). It only closes when the client sendsgrpc_request_cancelor the server explicitly setsisLast=true. - postMessage before webview is initialized:
postMessageToWebviewdirectly callsthis.webview?.webview.postMessage(message); if the webview is missing it returns undefined (postMessage undefined:189). Callers must tolerate this falsy return. - Config changes trigger a state push: listens on
vscode.workspace.onDidChangeConfiguration; whenevercline.mcpMarketplace.enabledchanges it callspostStateToWebview(config change:102), so the webview tracks setting changes. - Recording failures are not fatal:
withRecordingMiddlewareandrecordRequestare both wrapped in try/catch (recordRequest try:42); if recording breaks, the main flow is unaffected.
Summary
The webview bridge unifies extension↔React-sandbox communication into gRPC-over-postMessage. Every concrete business operation is a service.method call; unary pushes back once, streaming pushes partials multiple times. As a result ExtensionMessage has only one type, and adding a new call only requires registering a handler in generated code — types on both ends stay aligned automatically. To see how a concrete unary call lands on the Task, go to /startup/controller; to see how ask/say inside the Task becomes a chat message in the webview, go to /agent-loop/task-class.