用 SDK 的工具集类让 Claude 操作浏览器:自己写驱动,配网址策略、请求拦截和确认
Python 和 TypeScript 的 SDK 里有浏览器和电脑两个工具集类,替你做参数解析、网址检查、确认和结果拼装,你只写操作浏览器的那一层。本站用 Playwright 写了一个四个成员的驱动,实测各种情况下 Claude 读到什么,并直连官方让它真的打开网页、点链接。通过中转站时有一处不兼容。
本站已实测,测试日期:2026-10-15。
本页目录(12 节)
这篇教程解决一个问题:想让 Claude 自己开浏览器查东西、点页面,循环和各种检查不想从头写,怎么办。
上一篇讲现成工具的文章里,浏览器操作和电脑操作只讲了“工具集”这种写法:tools 里写一项,Claude 得到一组成员工具,调用怎么分发、结果怎么回都要你自己处理。官方的 Python 和 TypeScript SDK 在这上面又包了一层:一个浏览器工具集类,一个电脑工具集类,都还在测试阶段。用它们的话,你只写“驱动”,也就是真正操作浏览器或桌面的那部分,其余交给 SDK。
本站在服务器上写了一个驱动,把文档里说的各种情况逐个跑了一遍。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-13 |
| SDK | Python 版 anthropic 1.13.0 |
| 浏览器 | Playwright 1.64.0 带的无界面 Chromium,Ubuntu 24.04 |
| 模型 | Sonnet 5.5 |
| 连接方式 | 2026-10-15 直连官方接口(用的是 Max 套餐每月赠送的 API 额度);2026-10-13 先通过酷站AI 中转站测过一轮 |
先说结论:
- 直连官方,官方示例的写法原样能用。 把驱动交给 tool runner,三四轮就完成了“打开网页、点链接、读出标题”的任务;打开“边流式输出边开始执行”也一样能跑。
- 通过酷站AI 中转站,有一处不兼容。 第一次请求能通,把结果传回去时返回 400:SDK 在每个结果末尾附了一个
browser_state内容块,中转站不认识这种块,所以 tool runner 在第二次请求上就停了。把这个块去掉再发能走通,2026-10-13 那一轮就是这样测的;但这只是为了测试,正常用法不该去掉它,原因见后面。电脑工具集的结果里没有这个块,不受影响。
SDK 替你做什么,你要做什么
| 谁 | 做什么 |
|---|---|
| SDK | 把 Claude 的调用解析成带类型的参数;按你的设置关掉成员;调用你的网址策略、文件策略和确认函数;把驱动返回的东西拼成 tool_result;每次调用之后向驱动要一份浏览器状态;一轮里有调用失败时,后面的不再执行 |
| tool runner | 替你跑“请求、执行、回结果”的循环。不用它也可以,自己写循环 |
| 你(驱动) | 真的去开页面、截图、点击、输入,并报告现在开着哪些标签页 |
驱动怎么来,文档给了三条路:
- 用合作方现成的驱动。 文档列了四家:Browser Use 和 Browserbase 有浏览器工具集的驱动,Daytona 有电脑工具集的,E2B 两种都有。用现成的驱动,一个能开浏览器的智能体只要十来行。这条路本站没有测。
- 从官方的示例驱动改。
claude-quickstarts仓库里有一个通过 Chrome DevTools 协议控制 Chromium 的例子,文档说明它不是生产代码。 - 自己写。 文档把它归为进阶用法。下面走的是这一条。
写一个驱动
驱动是 BetaAbstractBrowserToolset20260801 的子类。浏览器工具集一共 31 个成员,你的浏览器支持哪个就实现哪个,没实现的 SDK 会在发给接口的工具项里标成关闭。我们实现了 4 个,发出去的配置里另外 27 个都是关着的。
文档的例子里有一个 backend,代表你自己对浏览器自动化库的封装。我们的 backend 是一个很薄的类:它启动一个 Node 子进程跑 Playwright,两边用一行一个 JSON 通信。这样做只是因为测试机上已经有 Node 版的 Playwright,不想再装东西;你用 Python 写的话,直接用 Python 版的 Playwright 更省事。启动那个子进程时,我们只传了几个必要的环境变量,没有把调接口用的 key 带给浏览器那一头,这也是文档的要求。
驱动本身:
import re
from urllib.parse import urlsplit
from anthropic.tools import ToolError
from anthropic.tools.browser import (
BetaAbstractBrowserToolset20260801,
BetaBrowserNavigateResult,
BetaBrowserState,
BetaDialogDismissed,
BetaNavigationRefused,
BetaScreenshotResult,
)
from anthropic.types.beta import BetaBrowserStateTabEntryParam
from backend import Backend, BackendError
SCHEME_PREFIX = re.compile(r"[a-z][a-z0-9+.-]*:", re.IGNORECASE)
def with_scheme(url):
return url if SCHEME_PREFIX.match(url) else f"https://{url}"
class PwBrowser(BetaAbstractBrowserToolset20260801):
def __init__(self, backend: Backend, **options):
super().__init__(**options)
self.backend = backend
# 每个驱动都必须有:现在开着哪些标签页,上次报告之后发生了什么
def _browser_state(self, context):
state = self.backend.call("state")
changes = [BetaNavigationRefused() for _ in range(state["refused"])]
changes += [
BetaDialogDismissed(kind=d["kind"], message=d["message"])
for d in state["dialogs"]
]
return BetaBrowserState(
tabs=[BetaBrowserStateTabEntryParam(**tab) for tab in state["tabs"]],
state_changes=changes,
)
def navigate(self, context, input):
history = ("back", "forward", "reload")
url = input.url if input.url in history else with_scheme(input.url)
try:
page = self.backend.call("goto", url=url, tab=input.tab_id)
except BackendError as exc:
raise ToolError(f"navigation failed: {exc}") from exc
return BetaBrowserNavigateResult(
url=page["url"], status=page["status"], title=page["title"]
)
def screenshot(self, context, input):
data = self.backend.call("shot", tab=input.tab_id)["data"]
return BetaScreenshotResult(data=data, media_type="image/png")
def get_page_text(self, context, input):
return self.backend.call("text", tab=input.tab_id)["text"]
def left_click(self, context, input):
if input.target.type != "coordinate":
raise ToolError("this driver only clicks by coordinate")
self.backend.call(
"click", tab=input.tab_id, x=input.target.x, y=input.target.y
)
def close(self):
super().close() # 先关工具集,保证没有调用还在用浏览器
self.backend.close()
几处要留意:
- 每个成员收到两个参数:这次调用的上下文,和已经解析成对象的输入(
input.url、input.tab_id这样取)。 - Claude 写网址可以不带协议,比如只写
example.com。驱动收到的是它的原话,所以navigate里要自己补上https://。 - 点击的目标有两种:页面上的坐标,或者
read_page、find返回的元素编号。我们没有实现后两个成员,所以只接受坐标,碰到编号就用ToolError告诉 Claude 换一种办法。 - 截图的大小要你自己管。 SDK 不读也不缩图,超过模型的图片上限接口会直接拒绝。我们把窗口固定在 1024×900。
成员返回什么,Claude 读到什么
不调接口也能看 SDK 的行为:自己造一个调用块,交给工具集的 tool_result,看它拼出什么。下面都是这样得到的真实输出。
| 调用 | Claude 读到 |
|---|---|
navigate,网址 example.com |
Navigated to https://example.com/ — Example Domain (HTTP 200) |
get_page_text |
页面的文字 |
get_page_text,页面是空的 |
(empty) |
screenshot |
一张图片 |
left_click |
Clicked. |
成功的结果末尾都跟着一个 browser_state 块,内容来自你的 _browser_state:
{"type": "browser_state",
"tabs": [{"tab_id": "tab_1", "title": "Example Domain",
"url": "https://example.com/", "active": true}]}
Claude 靠它知道现在开着哪些标签页、哪个是当前的。这就是前面说中转站不认识的那个块。去掉它,单标签页的简单任务还能做(它从 navigate 的那行文字里也看得到网址),但点链接跳到了哪、开没开新标签页,它就只能靠截图和读页面去猜了。
出错时读到的内容:
| 情况 | Claude 读到 | 这一轮 |
|---|---|---|
调了你没实现的成员(find) |
The browser toolset member 'find' is not available in this environment. |
继续 |
| 调了一个不存在的成员 | Error: unknown browser toolset member '…' |
继续 |
驱动抛出 ToolError |
你写的那句话 | 继续 |
| 标签页编号不对 | 我们在驱动里转成的 navigation failed: no such tab: tab_99 |
继续 |
_browser_state 自己抛了异常 |
什么也读不到,SDK 抛出 ToolsetUsageError |
停止(按文档,没有测) |
驱动里别让底层的异常直接漏出去。文档提醒,SDK 不会替你把本机路径从报错文字里去掉,所以要自己接住异常,换成你自己写的话再抛 ToolError。
跑起来:tool runner,或者自己写循环
官方的写法是把驱动实例直接放进 tools,交给 tool runner:
from anthropic import Anthropic
client = Anthropic()
with PwBrowser(backend, url_policy=url_policy) as browser:
runner = client.beta.messages.tool_runner(
model="claude-sonnet-5-5",
max_tokens=1024,
tools=[browser],
messages=[{"role": "user", "content": "Open example.com and tell me its heading."}],
)
for message in runner:
print(message)
直连官方,这段代码原样能跑。 任务换成“打开 example.com,点页面上的 Learn more 链接,告诉我落到的那一页的标题”,跑了两次:
[第 1 轮] navigate {"url": "https://example.com"};screenshot {}
[第 2 轮] left_click {"target": {"type": "coordinate", "x": 511, "y": 822}};wait {};get_page_text {}
[第 3 轮] get_page_text {};screenshot {}
[第 4 轮] The heading on the page I landed on is "Example Domains". Clicking "Learn more"
on example.com took me to an IANA (Internet Assigned Numbers Authority) page.
- 一共 9 秒。tool runner 回给模型的结果里,
browser_state块原样带着,接口照单全收。 - 第 2 轮它多调了一个我们没实现的
wait。按规则,这一轮里排在它后面的调用不再执行,所以第 3 轮它又读了一次页面。这条规矩 tool runner 替你守了。 - 输入从第 1 轮的 2,776 个 token 涨到第 4 轮的 6,140 个,涨的主要是截图。
边流式输出边开始执行: 给 tool runner 加上 stream=True 和 run_tools_eagerly=True,循环里拿到的是流,取 stream.get_final_message() 看结果。同样的任务三轮做完,8 秒,和不开时差不多。这么短的任务看不出差别,调用多、模型回复长的时候才有意义。
通过中转站,这段代码的第一次请求成功,第二次请求返回 400,原因就是前面说的那个块。
不用 tool runner 的话,循环自己写。要守一条规矩:一轮里有一个调用失败了,这一轮后面的调用就不能再执行,要用一句固定的话回它。下面是 2026-10-13 经中转站测试时实际用的循环,带一个只为了过中转站才加的开关;直连官方时把 STRIP 设成 False,或者干脆删掉那三行:
NOT_EXECUTED = "Not executed: an earlier action in this turn failed."
STRIP = True # 只在走中转站测试时用:把 browser_state 块去掉再发
messages = [{"role": "user", "content": task}]
for turn in range(10):
r = client.beta.messages.create(
model="claude-sonnet-5-5", max_tokens=1000,
tools=[browser], messages=messages,
)
messages.append({"role": "assistant", "content": r.content})
calls = [
b for b in r.content
if b.type == "tool_use" and b.toolset_name == browser.toolset_name
]
if not calls:
break
results, failed = [], False
for call in calls:
if failed:
results.append({
"type": "tool_result", "tool_use_id": call.id,
"toolset_name": call.toolset_name,
"content": NOT_EXECUTED, "is_error": True,
})
continue
res = browser.tool_result(call)
failed = bool(res.get("is_error"))
if STRIP:
kept = [b for b in res["content"] if b["type"] != "browser_state"]
res = {**res, "content": kept}
results.append(res)
messages.append({"role": "user", "content": results})
同样的任务,用这个循环经中转站跑(去掉了 browser_state 块),三轮做完:
[第 1 轮] navigate {"url": "https://example.com"}
-> Navigated to https://example.com/ — Example Domain (HTTP 200)
[第 1 轮] screenshot {} -> 一张截图
[第 2 轮] left_click {"target": {"type": "coordinate", "x": 511, "y": 822}}
-> Clicked.(标签页到了 https://www.iana.org/help/example-domains)
[第 2 轮] get_page_text {} -> Domains Protocols Numbers About … Example Domains …
[第 3 轮] The page you land on after clicking "Learn more" has the heading
"Example Domains". It's an IANA page, which is where the link goes.
它先截图找到链接的位置,再按坐标点。一轮里可以发几个调用,SDK 按它写的顺序一个一个执行。
网址策略:只管 navigate 里写的那个网址
不传网址策略时,SDK 不检查任何网址,接口那边也不过滤。策略是你写的一个函数,在 navigate 运行之前被调用,拿到的是 Claude 写的原话。要拒绝就抛 ToolError,Claude 会读到你写的那句话。
我们用的是文档里的示例策略,只放行两个站点和它们的子域名:
ALLOWED_HOSTS = ("example.com", "iana.org")
def is_allowed(url):
# 示例,不是能直接上生产的策略
if url.lower() == "about:blank":
return True
try:
parts = urlsplit(with_scheme(url).replace("\\", "/"))
except ValueError:
return False
host = parts.hostname or ""
listed = any(host == h or host.endswith(f".{h}") for h in ALLOWED_HOSTS)
return parts.scheme in ("http", "https") and listed
def url_policy(context, url):
if not is_allowed(url):
raise ToolError(f"blocked: {url} is not on an allowed host")
实测的结果:
| Claude 要去的网址 | 结果 |
|---|---|
example.com |
放行 |
https://www.wikipedia.org/ |
被拒,读到 blocked: … is not on an allowed host |
file:///etc/passwd |
被拒 |
javascript:alert(1) |
被拒 |
reload |
不经过策略,直接执行 |
后两种被拒是因为示例策略只认 http 和 https。这一条不能省:文档说 SDK 不检查协议,file: 能让 Claude 读到浏览器所在机器上的文件,javascript: 会在当前页面里执行脚本。
让模型去开名单以外的网站,它读到拒绝之后如实报告了,同一轮里跟在后面的截图没有执行:
[第 1 轮] navigate {"url": "https://www.wikipedia.org/"}
-> (报错)blocked: https://www.wikipedia.org/ is not on an allowed host
[第 1 轮] screenshot {} -> 没有执行(前一个动作失败了)
[第 2 轮] I couldn't open https://www.wikipedia.org/ because the browser tool
blocked it with the error "not on an allowed host." …
还有一个细节:把策略显式传成 None,SDK 会拒绝所有带网址的 navigate,Claude 读到 refused by the URL policy。这是故意的,免得配置里读出一个空值,网址就悄悄没人查了。实测时 SDK 还会往日志里打一段异常的堆栈,不影响运行。
请求拦截:点链接、跳转、页面自己发的请求
网址策略只看 navigate 调用里的网址。Claude 点一个链接、提交一个表单、页面跳转、页面上的图片和脚本,都不经过它,只有驱动看得见。所以文档要求第二层:在驱动里拦请求,用同一份规则。
Playwright 里是给浏览器上下文注册一个路由。我们那个 Node 子进程里是这样写的:
await context.route("**/*", (route) => {
const req = route.request();
if (isAllowed(req.url())) return route.continue();
const mainFrame = req.frame().parentFrame() === null;
if (req.isNavigationRequest() && mainFrame) refused += 1;
return route.abort("blockedbyclient");
});
被拦下的页面跳转记个数,下一次状态报告时每一次报一个 BetaNavigationRefused。
为了看效果,我们让浏览器这一层只放行 example.com,再做同样的任务。example.com 上那个链接指向 iana.org:
[第 2 轮] left_click {"target": {"type": "coordinate", "x": 511, "y": 822}}
-> Clicked.
A navigation was refused.
[第 2 轮] screenshot {} -> 一张截图(标签页的网址变成了 chrome-error://chromewebdata/)
[第 3 轮] get_page_text {} -> (empty)
[第 5 轮] I couldn't get the heading, because the click on "Learn more" didn't
take me to a new page. … the browser reported "A navigation was refused."
A navigation was refused. 这句话是 SDK 加的,不带网址。Claude 读到之后知道是被挡了,没有硬闯。
文档说明这样的拦截也有看不到的地方:WebSocket 握手、service worker 发的请求、跳转中间的每一跳。service worker 要整个禁掉,我们在创建浏览器上下文时加了 serviceWorkers: "block"。再往外一层是容器的出站规则,那是部署的事,本站没有测。
确认和成员开关
confirm 是一个返回真假的函数,SDK 在每个即将执行的调用之前问它。我们写了一个“导航一律不批”的版本来看顺序:
| 调用 | 结果 | confirm 被问到了吗 |
|---|---|---|
navigate,名单内的网址 |
被拒,读到下面那句话 | 问了 |
navigate,名单外的网址 |
被网址策略拒绝 | 没有问 |
get_page_text |
执行 | 问了,放行 |
被 confirm 拒绝时 Claude 读到的是:
The user did not grant permission to run 'navigate'. Do not retry it unless the user asks you to.
顺序是先网址策略,再 confirm,已经被拒的调用不会再去问人。confirm 能拿到成员的名字、解析好的输入,以及目标标签页在上一次状态报告里的网址。
有两个成员默认是关的,而且硬性要求有 confirm:javascript_exec(在页面里执行脚本)和 file_upload。我们给驱动加了一个 javascript_exec 来验证:
| 设置 | 结果 |
|---|---|
| 实现了,不打开 | 构造成功。按文档,这个成员默认是关的,不提供给 Claude |
打开,不给 confirm |
构造时报配置错误,说它需要一个 confirm |
打开,给 confirm |
构造成功 |
文档提醒了一点:付款、发消息、同意条款,都是通过普通的点击和输入完成的,confirm 没法按成员名字把它们挑出来。想让人来批这些动作,就得对点击和输入也问。
configs 用来开关成员,只写你要改的那几个:
browser = PwBrowser(backend, configs={"navigate": {"enabled": False}})
关掉之后 Claude 再调 navigate,读到的是这个成员不被本应用的权限允许。反过来,打开一个驱动没实现的成员,构造时就报配置错误。
电脑工具集有什么不同
电脑工具集类是 BetaAbstractComputerToolset20260801,写法一样:一个工具一个方法。按文档,不同的地方有这些:
- 没有网址策略、文件策略和状态报告,这三样是浏览器才有的。
- 你实现的工具默认都是开的。
- 实现了
type、key、hold_key这三个键盘工具,就必须给confirm,或者用configs把它们关掉,否则构造时报错。没有confirm时,包括每一次点击在内的所有调用都直接执行。 - 坐标要你自己换算。 Claude 给的每个点都是你返回的截图里的像素位置。截图和真实屏幕大小不一样时,两个方向都要在驱动里换算,整个会话里截图的尺寸保持不变。
- 一轮里失败之后,回给后面那些调用的固定句子和浏览器的不一样,中间多一个
computer。
本站对它只做了一个最小的测试:一个只实现了截图的假桌面,返回一张 320×200 的灰色图片。通过中转站,一个来回走通了,模型描述了它看到的画面。真的桌面没有搭,官方接口上也没有再测。
上线之前
上面的驱动只能对着一个用完就扔的浏览器跑。文档列了六步,SDK 只负责其中的第 1、4、5 步,其余靠你的驱动和部署:
- 写网址策略,任务用不到的网址一律拒绝。
- 在驱动里拦请求,用同一份规则。
- 给容器加出站规则:封掉内网和链路本地地址(包括云主机的元数据地址),只放行任务需要的主机。
- 管住上传和下载。上传默认是关的;要开的话,给一个只装任务文件的上传目录。
- 用
confirm把有实际后果的成员交给人批。 - 每个会话用一个专用的、权限最小的容器或虚拟机跑浏览器,用全新的浏览器配置,不要登录任何你不愿意交给 Claude 的账号。调接口的那段代码放在这个容器外面,因为 key 和对话都在它那里。
页面返回的一切都当成不可信的:正文、截图、标签页标题、下载的文件名。页面上的文字可以冒充指令,让 Claude 去访问内网的服务或者做有后果的操作。前面每一层检查,防的都是这个。
用量
工具集的定义按成员计。只实现 4 个成员时,一次请求的输入是 2,779 个 token,同一句话不带工具是 46 个。上一篇里测的是全部成员都提供时的数字,有六千多。所以用不到的成员不要实现,或者用 configs 关掉。
经中转站那一轮发了二十来次请求,直连官方的补测又跑了两遍完整任务,都用的 Sonnet 5.5,花费以美分计。
这次实测没有覆盖到的
- 网址策略、请求拦截、确认这些情况是在本地拼结果看的(“成员返回什么”一节),以及经中转站跑的;直连官方只跑了完整任务的两遍。
- 只实现了 4 个成员。 按元素编号点击、输入、多标签页、读控制台、上传下载都没有做。
- 文件策略、
execute的前后钩子没有试。 - 合作方的驱动和官方的示例驱动没有试。
- TypeScript 版没有测。 按文档,写法对应,成员要写成方法不能写成箭头函数,
type成员在 TypeScript 里叫type_。 - 电脑工具集只做了一次假桌面的来回。
- 容器的出站规则、隔离这些部署上的事没有涉及。
- 每种情况只跑了一次。
相关文章
- 工具集这种写法本身、成员清单、批量动作:Claude API 的现成工具。
- 工具调用的基本循环和 tool runner:Claude API 工具调用入门。
- 通过中转站调接口,哪些功能能用:通过中转站用 Claude API。
- 在 Claude Code 里让它操作浏览器,不用自己写程序:前端联调:让 Claude Code 自己开浏览器验证改动。