综合 / 开发工具 · 2026-10-22 14:27 · 1 阅读 · 0 赞
把 Claude Code 搬进浏览器:自研 Web 交互工作台实战
基于 @anthropic-ai/claude-agent-sdk,用 Node + WebSocket 把 Claude Code 从终端搬到浏览器,实现流式输出、工具调用可视化与权限审批回灌,让 Agent 工作流可观察、可拦截、可编排。
一、前言
哈喽,我是 alien。在上一篇文章《求求你别再只用 Claude Code 写代码了!解锁一下它全能玩法?》里,我们介绍了 Claude Code 的一些其他使用场景。今天顺着这个思路,聊一聊怎样在浏览器里更便捷地使用 Claude Code,同时分享一些基于它的奇思妙想。
通过本文你能收获:
- Claude 运行到终端之外的新思路;
- claude-agent-sdk 的介绍与实战;
- 如何让 Claude Code 的工作流变得可控。
在使用传统 Claude Code 的过程中,我们常遇到下面两类困扰。
场景一:对非研发用户不友好
Claude Code 本质上是一个 agent,除了写代码,也能帮我们处理流程化的工作。但对非研发工程师来说,它的上手成本比直接用豆包之类的 bot 高不少——skills 管理、MCP 管理、模型切换等等都是门槛。那么能不能把 Claude Code 搬进浏览器,做一个对用户更友好的交互界面?
场景二:结果难以干预
Claude Code 一般通过 CLI 在终端里运行、用命令交互,我们很难干涉它返回的结果。尤其是想基于 Claude Code Agent 自定义工作流,或者对返回结果做二次封装和加工时,就更麻烦了。

下面就从这两个问题入手,看看怎样把 Claude Code 搬进浏览器,并对它的启动、流程和输出结果做到有效控制。
二、基本实现思路
2.1 基本原理图
Claude Code 本身基于 Node.js 运行时。想更灵活地使用它,可以直接走 CLI;远程场景下,可以用 Docker 等方案给它提供一个稳定的沙箱环境。先看设计原理:

- 创建一个 Node 服务,把 canUseTool 审批请求异步推到浏览器,由人/机器人决策后回灌;
- 用 WebSocket 连接 Node 和浏览器(也可以用 HTTP 流式响应);
- Node 接收 Web 端的 query 信息,处理后传给 Agent SDK;
- Agent SDK 负责处理 Claude Code 子进程逻辑。
一次端到端交互大致如下:

整个流程里非常关键的一环就是 claude-agent-sdk,先来了解它。
2.2 claude-agent-sdk
@anthropic-ai/claude-agent-sdk 是 Anthropic 官方出品的库,可以理解为「把 Claude Code 这个 CLI 当成一个可被 JS 程序驱动的高级对象」。
与 @anthropic-ai/sdk 不同,claude-agent-sdk 在它之上封装了:
- Claude Code 的完整工具集(Read / Edit / Write / Bash / Grep / Glob / WebFetch ...);
- Skills / MCP / CLAUDE.md 的加载与解析;
- 多轮会话持久化(session_id + --resume 同款语义);
- 权限审批(canUseTool 异步回调);
- 流式事件(stream_event 透传原始 Anthropic 事件)。
这样就能通过 API 的方式操作 Claude Code CLI,还能读取当前应用上的 Claude Code 配置,以及它注册的 skills 和 plugins。
基本用法:
import { query } from '@anthropic-ai/claude-agent-sdk';
const stream = query({
prompt: '在当前目录新建 hello.txt',
options: {
cwd: '/path/to/project',
settingSources: ['user', 'project', 'local'],
includePartialMessages: true,
permissionMode: 'default',
canUseTool: async (toolName, input, ctx) => { /* ... */ },
},
});
for await (const msg of stream) {
// msg 是 SDKMessage 的子类型之一
console.log(msg);
}
options 的核心参数:
cwd: string—— 工作目录;settingSources: ('user' | 'project' | 'local')[]—— 加载哪些配置,如settingSources: ['user', 'project', 'local'];
| 值 | 读什么 |
|---|---|
'user' |
~/.claude/ 全局配置 |
'project' |
cwd 下的 .claude/ |
'local' |
.claude/settings.local.json |
includePartialMessages: true—— 流式逐字的关键;permissionMode: 'default'—— 权限模式;canUseTool—— 异步权限回调;signal: AbortSignal—— 回合中断。
了解 SDK 之后,看一下简要的实现逻辑:

简单说明:
- 模型用的是 MiniMax-M3,整体体验不错,写代码、做一些日常流程化的工作基本够用;
- 为了演示对 Claude Code 的「劫持」效果,我对每轮对话的时长和 token 做了简单记录。提前声明:token 消耗数据不一定准确。
2.3 简要实现
2.3.1 整体流程

一次 user_message 的链路:
- 浏览器输入 →
user_message帧 → Node; query({ prompt, options })起 SDK 会话 → spawn Claude Code 子进程;- SDKMessage 流式回灌 →
normalize()归一化 → 帧推到浏览器; text_delta帧逐字渲染;- 危险工具触发
canUseTool→permission_request帧推到浏览器; - 用户决策 →
permission_response回灌 → Promise resolve; - 回合结束 →
turn_result+done帧 → 记录sessionId供下次续接。
2.3.2 Node 端代码片段(core/session.js)
这个文件干两件事:驱动 Claude Code + 把审批推到浏览器等回灌。其他都是细节。
import { query } from '@anthropic-ai/claude-agent-sdk';
class CodeSession {
constructor({ cwd, onFrame }) {
this.cwd = cwd;
this.onFrame = onFrame; // 回帧的回调,由 server.js 注入成 ws.send
this.sessionId = null; // 首轮拿到后存下来,后续 resume 用
this._permIdSeq = 0; // 审批请求自增 id,用来对账
this._pendingPerms = new Map(); // id -> resolve 函数,审批回灌时取
this._abort = null; // 当前回合的 AbortController
}
async send(prompt) {
this._abort = new AbortController();
const options = {
cwd: this.cwd,
// 关键:不传这个字段,SDK 不会读 ~/.claude/,你的全局 skills/MCP 全没
settingSources: ['user', 'project', 'local'],
includePartialMessages: true, // 不开就没法逐字流式,只能拿到整块
permissionMode: 'default', // 危险工具走下面那个回调
canUseTool: this._canUseTool.bind(this),
signal: this._abort.signal,
};
if (this.sessionId) options.resume = this.sessionId; // 第二轮起续接
try {
const stream = query({ prompt, options });
for await (const msg of stream) {
// 只在第一次出现时记录,后续消息不再覆盖
if (msg.session_id && !this.sessionId) this.sessionId = msg.session_id;
for (const frame of normalize(msg)) this.onFrame(frame);
}
this.onFrame({ type: 'done', sessionId: this.sessionId });
} catch (err) {
// 用户点停止 ≠ 错误。前端要的不是弹错,是"已取消"提示
if (this._abort.signal.aborted) {
this.onFrame({ type: 'done', aborted: true });
} else {
this.onFrame({ type: 'error', message: err.message });
}
}
}
stop() { this._abort?.abort(); }
resolvePermission(id, decision) {
// 浏览器回灌的入口,从 Map 里捞对应的 resolve 函数
this._pendingPerms.get(id)?.(decision);
}
// 整份代码最绕的一段,但逻辑不复杂:
// SDK 调这个函数要个 Promise(异步),我们就地造一个推到浏览器,等回灌
_canUseTool(toolName, input, ctx) {
const id = ++this._permIdSeq;
this.onFrame({ type: 'permission_request', id, toolName, input });
return new Promise((resolve) => {
const finish = (decision) => {
this._pendingPerms.delete(id);
// 注意:allow 分支必须回传 updatedInput,
// SDK runtime 用 Zod 强校验,只给 behavior 会炸
resolve(
decision === 'allow'
? { behavior: 'allow', updatedInput: input }
: { behavior: 'deny', message: '用户在浏览器端拒绝' }
);
};
this._pendingPerms.set(id, finish);
// 5 分钟没人理就当拒绝,防止浏览器挂着导致 Promise 永远不结算
setTimeout(() => finish('deny'), 5 * 60 * 1000);
});
}
}
// 归一化:把 SDK 的多类型事件压平成前端好处理的几种帧
// 不做这层,前端要认识 20+ 种 SDK 内部事件,改造不动
function normalize(msg) {
if (msg.type === 'system' && msg.subtype === 'init') {
return [{ type: 'system_init', model: msg.model, tools: msg.tools }];
}
// 流式文本:只挑这一种 delta,其他 stream_event 类型丢掉
if (msg.type === 'stream_event' && msg.event?.delta?.type === 'text_delta') {
return [{ type: 'text_delta', text: msg.event.delta.text }];
}
// assistant 消息里文本块已经由上面推过了,这里只取工具调用
if (msg.type === 'assistant') {
return msg.message.content
.filter(b => b.type === 'tool_use')
.map(b => ({ type: 'tool_use', id: b.id, name: b.name, input: b.input }));
}
if (msg.type === 'user') {
return msg.message.content
.filter(b => b.type === 'tool_result')
.map(b => ({ type: 'tool_result', id: b.tool_use_id, content: b.content }));
}
if (msg.type === 'result') {
return [{ type: 'turn_result', cost: msg.total_cost_usd, usage: msg.usage }];
}
return []; // 其他控制类事件一律不转发,免得前端噪音
}
几个值得说的点:
settingSources不传或传空数组 = 拿不到全局 skills/MCP,这是 claude code for web「零配置」的关键开关。includePartialMessages: true是流式的命门,关掉就只有完整块,逐字渲染只能装样子。canUseTool里allow必须回updatedInput,SDK runtime 的 Zod schema 比 .d.ts 严格——只看类型定义写代码必踩这个坑。- 5 分钟超时是为了防止浏览器关掉、SDK 内部 Promise 永远挂着。这不是 nice-to-have,是必须。
normalize()是解耦的关键:前端只认 N 种帧,不感知 SDK 内部协议。以后换 SDK 版本、加 MCP、改流式策略,前端都不用动。
2.3.3 前端代码片段(web/app.js)
前端就一个 dispatch:收到帧,switch 到对应渲染函数。没有业务逻辑,所有「判断」都在 Node 端做完了。
const ws = new WebSocket('ws://127.0.0.1:1717');
ws.onmessage = (e) => {
const frame = JSON.parse(e.data);
switch (frame.type) {
case 'system_init':
// 会话刚建立时来一次,告诉前端当前用啥模型、能调啥工具
renderStatus(`Model: ${frame.model} · ${frame.tools.length} tools`);
break;
case 'text_delta':
// 流式逐字的核心:每来一点就追加到当前 assistant 气泡
appendToCurrentBubble(frame.text);
break;
case 'tool_use':
// Claude 想调工具了,开个折叠卡片占位,等结果回填
createToolCard(frame.id, frame.name, frame.input);
break;
case 'tool_result':
// 工具跑完了,把结果塞回刚才那张卡片
fillToolCard(frame.id, frame.content);
break;
case 'permission_request':
// 危险操作:弹横条,等用户点;点了之后把决定发回 Node
showApprovalBar(frame, (decision) => {
ws.send(JSON.stringify({
type: 'permission_response',
id: frame.id,
decision, // 'allow' | 'deny'
}));
});
break;
case 'turn_result':
// 整轮跑完的账单:花了多少钱、用多少 token
renderMeta({ cost: frame.cost, duration: frame.duration });
break;
case 'done':
unlockInput();
if (frame.aborted) showBanner('已取消');
break;
case 'error':
showError(frame.message);
unlockInput();
break;
}
};
sendBtn.onclick = () => {
const text = inputEl.value.trim();
if (!text) return;
lockInput();
ws.send(JSON.stringify({ type: 'user_message', text }));
};
stopBtn.onclick = () => ws.send(JSON.stringify({ type: 'stop' });
几个值得说的点:
- 这个文件几乎没有状态,就是一个
switch。所有「Claude 在干嘛 / 该不该锁输入框 / 该不该渲染」的状态机都在 Node 端,前端只负责表现。 permission_request的处理是唯一一处异步等待——其他都是收到帧立刻渲染。这里要把 UI 锁住等用户点,点了才发回去。text_delta真正实现时要做 buffer 和安全边界——别一收到就innerHTML += text,半截反引号会让 Markdown 解析器发疯。这块代码省略了,见项目里的app.js。aborted字段决定done是「用户主动停」还是「正常结束」,UI 上要区分(已取消 vs 解锁输入框)。
2.3.4 协议帧(双向)
协议设计原则:每条消息只做一件事。浏览器发 3 种(Node 只关心「你要啥」+「你决定了啥」+「你要停」),Node 发 8 种(浏览器要感知生命周期里的关键节点)。
浏览器 → Node(3 种):
| 类型 | 字段 | 用途 |
|---|---|---|
user_message |
text |
用户输入 |
permission_response |
id, decision |
审批回灌 |
stop |
— | 中断当前回合 |
Node → 浏览器(8 种):
| 类型 | 字段 | 用途 |
|---|---|---|
system_init |
model, tools, mcpServers |
会话初始化(每连接来一次) |
text_delta |
text |
流式文本增量(一次回合里推 N 次) |
tool_use |
id, name, input |
工具调用开始 |
tool_result |
id, content, isError |
工具执行结果 |
permission_request |
id, toolName, input |
请求审批 |
turn_result |
cost, usage, duration, numTurns |
整轮账单 |
done |
sessionId, aborted? |
回合结束 |
error |
message |
异常 |
协议本身没有 schema 文件,字段约定靠代码。新增帧的成本很低(加 case 即可),改字段名才会炸到对方——所以字段名一旦定下来就别乱动。
三、完成效果演示

四、总结
写到这里,Claude Code 已经从一条冰冷的终端命令,变成了浏览器里一扇可以随手推开的窗。
回头看这条链路,其实并没有多玄妙:Node 端是心脏,浏览器是脸,WebSocket 是血管,协议帧是神经末梢。我们没有重新发明 Agent,只是把原本埋在终端黑盒里的「启动 / 审批 / 输出」三件事,拆成了一段段可以观察、可以拦截、可以编排的帧。
技术这件事常常如此——真正难的从来不是造一个能跑的东西,而是让一个能跑的东西变得「可被使用」。把 CLI 塞进 Web 是一小步,但它撬动的,是 Agent 走向普通用户、走向真实工作流的可能。
如果这篇文章给了你一点新思路,欢迎点赞 / 收藏,你的支持是我继续把 Claude Code 玩出花的底气 🚀。