一 前言
哈喽,我是 alien , 在上一篇文章《求求你别再只用 Claude Code 写代码了!解锁一下它全能玩法?》我们介绍了 Claude Code 的一些其他场景的使用案例,今天我们来顺着 Claude Code 思路,聊一聊我们怎么样在浏览器里面便捷的使用 Claude Code ,同时我也会分享基于 Claude Code 的一些奇思妙想。
通过本篇文档你能收获到哪些内容?
- claude 运行到终端的新思路;
- claude-agent-sdk 介绍和使用实战;
- 如何让 claude code 的工作流变的可控制的。
我们在使用传统的 claude code 过程中,可能有如下使用困扰场景:
场景一:对于一个非研发的使用者来说不友好
我们都知道 Claude Code 本质上是一个 agent, agent 除了写代码之外,也能够帮我们做一些流程化的与工作相关的事情,来为工作赋能,对于非研发工程师来说,Claude Code 上手成本可能要比使用豆包 bot 交互要复杂很多,包括 skills 管理,mcp 管理,模型切换等等。
那么可以不可以把 Claude Code 搬进到浏览器中,编程对于用户更友好的终端交互界面。
场景二: Claude Code 一般场景下都是通过 Cli 运行在终端,通过终端命令进行交互的。这样我们对于 Claude Code 返回的结果很难去干涉,尤其是我们想要基于 Claude Code Agent 自定义一个工作流,或者是基于 Claude Code 返回的结果进行二次封装或者加工。

那么我们今天针对如上两个问题入手,看一下怎么样把 Claude Code 搬进浏览器,并且可能做到对 Claude Code 的启动,流程,输出结果进行有效控制。
二 基本实现思路
2.1 基本原理图
Claude Code 本身基于 Node.js 运行时环境,如果想要更灵活的运用 Claude Code ,本可以直接用 Cli 的方式,远程的场景下,可以通过 docker 等方案给 Claude Code 提供一个稳定的沙箱环境。 我们直接来看一下设计原理:

- 创建一个 Node 服务,异步化 canUseTool------审批请求推到浏览器,人/机器人决策后回灌;
- 通过 WebSocket 链接 Node 和浏览器,这些也可以用 http 流式响应;
- Node 接收 web 端的 query 信息,处理 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 ,同样还可以读当前应用上的 Claude Code 配置,以及 Claude Code 注册的 skills 和 plugins。
我们来看一下基本使用:
js
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'];
- settingSources: ('user' | 'project' | 'local')\[\] ------ 加载哪些配置
| 值 | 读什么 |
|---|---|
'user' |
~/.claude/ 全局配置 |
'project' |
cwd 下的 .claude/ |
'local' |
.claude/settings.local.json |
-
- includePartialMessages: true ------ 流式逐字的关键;
-
- permissionMode: 'default' ------ 权限模式;
-
- canUseTool ------ 异步权限回调;
-
- signal: AbortSignal ------ 回合中断;
介绍 claude-agent-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 + 把审批推到浏览器等回灌。其他都是细节。
js
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 端做完了。
js
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 即可),改字段名才会炸到对方------所以字段名一旦定下来就别乱动。
三 完成效果演示:

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