这篇记录一下 SayCraft 现在最核心的一块东西:我是怎么把 Claude Code 放进一个沙箱里,让它一边写代码,一边让用户在浏览器里看到页面实时长出来的。
如果只从外面看,SayCraft 像是一个“你说需求,AI 帮你做网页”的产品。但里面真正难的部分不是“调用一个大模型”,而是让这个过程稳定、可观察、可回放,而且用户不是开发者,不会帮你看 terminal,也不会知道什么时候该刷新页面。
我最后选的形态是:会议系统只负责理解和排计划;真正写代码的是 E2B 沙箱里的 Claude Code CLI;它直接改
/workspace,Vite 负责把变化实时展示给用户。
为什么不是“直接让模型写代码”
一开始很容易把这件事想简单:用户说一句话,模型输出一些代码,我把文件写进去,预览一下就好了。
但真跑起来会发现,这个想法有点天真。一个真实网页不是一个单文件答案,它会经历很多细碎动作:建页面、加组件、补样式、接 mock data、修类型错误、看 Vite 日志、补图片、再改 router。更重要的是,用户正在看着屏幕等结果。
所以这里面有三个约束:
- 代码必须真的落地到一个可运行项目里,不是停在聊天记录里。
- 过程要可见,最好页面能一块一块出现,而不是五分钟后突然闪一下。
- 边界要可靠,哪一步完成、哪一步失败、哪些文件改了,都要有系统记录。
Claude Code 刚好适合“在一个真实项目里动手做事”。它会读文件、写文件、跑命令、修错。于是我没有把它当成一个普通 LLM API,而是把它当成一个被产品调度的“代码执行进程”。
控制面:Coordinator 不写代码,只写计划
SayCraft 的会议里有一个 Coordinator。它的职责不是写代码,而是理解用户刚刚说了什么,更新产品意图、选择模板、拆出 plan items。
这一步有点像产品经理:它要判断“用户到底想做什么”,而不是马上打开编辑器开干。真正决定什么时候启动 Claude Code 的,是另一个普通 TypeScript 服务:Coding Reconciler。
Reconciler 做的事很朴素:
- 看数据库里有没有
pending的 plan item。 - 看这个 meeting 有没有正在跑的 foreground Claude。
- 如果没有,就启动一个新的 Claude Code 进程。
- 如果有,就先别打扰它,等这轮结束后再
--resume下一批。
这里有一个很重要的经验:不要在 Claude 正跑到一半时继续往 stdin 里塞新需求。 我们实测过,mid-turn 的消息很容易被当前 run 合并、忽略,最后看起来像用户补充了需求,但 Claude 没真正处理。后来我干脆把规则改成:新需求先留在数据库里,等 SessionEnd 后用新的 --resume 进程处理。
这类系统里,“看起来更实时”的方案不一定更可靠。稳定的边界比一时的丝滑更重要。
执行面:一个 E2B 沙箱就是一个临时工作台
每个 meeting 会有一个 E2B sandbox。里面有一个真正的 Vite 项目,主目录是 /workspace。
Claude Code 不是在我的 API 容器里本地跑,而是在这个 sandbox 里跑。这样它看到的文件、执行的命令、改出来的页面,都属于这次 meeting 的临时环境。用户看到的 live preview,也是这个 sandbox 里的 Vite 服务。
启动时大概会做这几件事:
- 确认模板已经初始化到
/workspace。 - 确认 Vite preview 已经起来。
- 构造本轮 Claude Code prompt。
- 把 provider env、model、hook settings 传进去。
- 用 wrapper 启动
claude -p --input-format stream-json。
这里我没有直接用 Claude Agent SDK,而是自己用 CLI 拼了这一层。原因很现实:SDK 底层也是 spawn Claude Code 进程,而我们的进程必须跑在 E2B 里面,且还要配合 sandbox 的 stdout、hooks、文件 tail、pause/resume。也就是说,真正麻烦的不是“怎么调用 Claude”,而是“怎么管理这个跑在远端沙箱里的 Claude”。
四类配置同时生效
Claude Code 启动前,不是只吃一个 prompt。它同时受到几类配置影响:
- 模板里的
CLAUDE.md:告诉它这个项目的目录结构、依赖、组件库、哪些文件不要改、图片 slot 怎么写。 - Langfuse 里的
code-session-foregroundprompt:告诉它这轮怎么构建,尤其是“先搭结构,再一块一块填 section”。 - 每次 spawn 写入的
/tmp/cs-settings...:配置 Stop hook / SessionEnd hook,并关闭自动 Co-Authored-By。 - Provider env:比如
ANTHROPIC_BASE_URL、CLAUDE_CODE_MODEL,决定实际走哪个模型和网关。
这个分层挺重要。CLAUDE.md 是模板知识;Langfuse prompt 是运行策略;settings 是进程级回调;env 是模型和网络。它们混在一起会非常难维护,分开以后心智负担小很多。
为什么既让 Claude 自己 commit,又有 Stop hook 兜底
这块我一开始也绕过弯。
现在的 prompt 会要求 Claude 在页面生长的过程中主动 commit:先 scaffold,再每个 section,再 Phase 2。这样做的好处是 replay 可以看到网页一段一段长出来,image-filler 也能在每个阶段后开始替换真实图片。
但只靠 Claude 自己 commit 又不够可靠。模型可能忘了,可能 type-check 后没提交,也可能刚好有一点尾巴没收干净。所以 Stop hook 会在每个 agent run 边界做一次兜底:
git add -A
if git diff --cached --quiet; then
# 没有新 diff,说明 Claude 已经提交过了
else
git commit -m "claude run boundary"
fi
这就形成了一个比较舒服的关系:Claude 的 commit 是主动的阶段 checkpoint;系统的 commit 是保险丝。 如果 Claude 已经提交,hook 就 no-op;如果还有漏网之鱼,hook 帮它补上。
两个踩坑:SessionStart 和 secret
有两个小坑我印象很深。
第一个是 SessionStart hook。我们曾经想在 SessionStart 时把项目快照注入给 Claude,听起来很聪明:少读文件,启动更快。结果 Claude Code 的 Write 工具有自己的规则:写某个已存在文件前,需要通过 Read 工具读过它。系统 reminder 里塞了文件内容,并不等于工具层面“读过”。最后出现了“模型以为自己看过,工具认为没看过”的错位。
所以 SessionStart hook 后来被删了。现在只保留 Stop 和 SessionEnd。
第二个是 hook settings 不能放在 /workspace。因为 Stop hook 会 git add -A,如果 settings 里有真实的 hook secret,而文件又在 workspace 里,那就有可能被提交进仓库。这个听起来很低级,但这种系统性低级错误才最吓人。现在 settings 一律写到 /tmp。
我现在对这套架构的理解
这套东西看起来像是“AI 写代码”,但我越来越觉得,它更像一个小型操作系统:
- Coordinator 是产品层的调度器。
- Reconciler 是任务队列和状态机。
- E2B sandbox 是进程隔离和临时文件系统。
- Claude Code CLI 是真正的代码执行 worker。
- hooks 是进程生命周期事件。
- git commit 是可回放、可归档、可恢复的边界。
如果把 AI 产品只理解成“发 prompt,收回答”,很多问题会被藏起来。真正上线以后,问题反而都在 prompt 外面:进程怎么死、状态怎么恢复、用户怎么看到进度、secret 会不会漏、模型忘记 commit 怎么办。
做 AI coding 产品,模型能力当然重要,但更重要的是把模型放进一个有边界、有回调、有审计的运行时里。
SayCraft 现在还远没到我满意的程度,但这套“会议计划 → reconciler → E2B Claude Code → live preview → hook commit”的形态,至少已经让我觉得方向是对的。它不是最简单的实现,但它解释了为什么用户能“说一句话,然后看着网页真的长出来”。
产品在这里:SayCraft。之前录过一个 demo,也可以看:YouTube demo。