pi-cloudflare-browser-run 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/package.json +37 -0
- package/src/api.ts +135 -0
- package/src/index.ts +124 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexandre@AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# pi-cloudflare-browser-run
|
|
2
|
+
|
|
3
|
+
Pi extension that gives the model **web browsing tools** backed by
|
|
4
|
+
[Cloudflare Browser Run](https://developers.cloudflare.com/browser-run/) —
|
|
5
|
+
headless Chrome on CF's network. Pages render with a real browser, so
|
|
6
|
+
JS-heavy/SPA sites work, and the request comes from Cloudflare's network
|
|
7
|
+
(not your machine).
|
|
8
|
+
|
|
9
|
+
No Workers, no proxy: the extension calls Browser Run Quick Actions over
|
|
10
|
+
the v4 REST API directly.
|
|
11
|
+
|
|
12
|
+
## Tools
|
|
13
|
+
|
|
14
|
+
| tool | what it does |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `browse` | fetch a public URL, return clean **markdown** text (default; also `screenshot` / `pdf` actions) |
|
|
17
|
+
| `screenshot` | save a PNG of the page locally, returns the file path |
|
|
18
|
+
| `pdf` | save a PDF of the page locally, returns the file path |
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi install npm:pi-cloudflare-browser-run
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Restart `pi` if it was already running.
|
|
27
|
+
|
|
28
|
+
## Auth (required)
|
|
29
|
+
|
|
30
|
+
The tools call Cloudflare Browser Run on your behalf, so they need a Cloudflare
|
|
31
|
+
API token with **Browser Rendering: Edit** permission plus your account id.
|
|
32
|
+
|
|
33
|
+
### 1. Create the token (one time)
|
|
34
|
+
|
|
35
|
+
1. Open https://dash.cloudflare.com/profile/api-tokens
|
|
36
|
+
2. **Create Token** → under *Start with a template* pick
|
|
37
|
+
**"Browser Rendering: Edit"** (or *Create Custom Token* and add the
|
|
38
|
+
**Browser Rendering — Edit** permission)
|
|
39
|
+
3. Copy the token (shown once) — it starts with a random string
|
|
40
|
+
4. Find your account id: dashboard URL is
|
|
41
|
+
`dash.cloudflare.com/<ACCOUNT_ID>/...` (also shown under the account's
|
|
42
|
+
*Overview* or in the Workers dashboard)
|
|
43
|
+
|
|
44
|
+
### 2. Give the extension the credentials
|
|
45
|
+
|
|
46
|
+
Create `~/.pi/agent/cloudflare-browser-run.json` (keys use the `a_b_c`
|
|
47
|
+
form, `cf_` prefix):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"cf_api_token": "paste-your-token-here",
|
|
52
|
+
"cf_account_id": "paste-your-account-id"
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Optional: `"cf_api_base"` (default `https://api.cloudflare.com/client/v4`).
|
|
57
|
+
|
|
58
|
+
If either value is missing the tools reply with a setup hint instead of
|
|
59
|
+
erroring — nothing breaks.
|
|
60
|
+
|
|
61
|
+
### 3. Verify
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
curl -X POST \
|
|
65
|
+
"https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/browser-rendering/markdown" \
|
|
66
|
+
-H "Authorization: Bearer <TOKEN>" \
|
|
67
|
+
-H "Content-Type: application/json" \
|
|
68
|
+
-d '{"url":"https://example.com"}'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A `200` with markdown text means the token works.
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
Ask the model to browse something — it will pick the tool itself:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
> what's on the Cloudflare blog today?
|
|
79
|
+
> open https://example.com and summarize it
|
|
80
|
+
> screenshot https://news.ycombinator.com
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Screenshots/PDFs are saved under
|
|
84
|
+
`/tmp/pi-cloudflare-browser-run/` (macOS/Linux) and the returned path can
|
|
85
|
+
be opened directly.
|
|
86
|
+
|
|
87
|
+
## Security
|
|
88
|
+
|
|
89
|
+
- **Public web only**: every URL passes an SSRF guard before the API is
|
|
90
|
+
called — non-http(s) protocols, localhost, private/reserved IPs, IPv6
|
|
91
|
+
literals, userinfo, and oversized URLs are rejected.
|
|
92
|
+
- The token is read from the environment at load time; it is never logged
|
|
93
|
+
and never written to disk by this extension.
|
|
94
|
+
- Browser Run itself identifies its traffic as a bot (`Well-behaved Bot
|
|
95
|
+
Mode`), which is the compliant way to scrape.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npm install
|
|
101
|
+
npm run typecheck
|
|
102
|
+
npm test
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-cloudflare-browser-run",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: web browsing tools (markdown / screenshot / pdf) powered by Cloudflare Browser Run. Headless Chrome on CF's network — JS-rendered pages, login sessions, WebMCP-capable.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test:unit": "node --import tsx --test tests/unit-*.mjs",
|
|
11
|
+
"test": "npm run test:unit",
|
|
12
|
+
"typecheck": "tsc --noEmit"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"pi",
|
|
21
|
+
"pi-extension",
|
|
22
|
+
"cloudflare",
|
|
23
|
+
"browser-run",
|
|
24
|
+
"browser",
|
|
25
|
+
"agent"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"typebox": "^1.3.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": "^0.83.0",
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"tsx": "^4.19.0",
|
|
35
|
+
"typescript": "^5.5.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Cloudflare Browser Run REST client.
|
|
2
|
+
// Quick Actions are one-shot HTTP calls: headless Chrome on CF's network
|
|
3
|
+
// renders the URL and returns markdown / PNG / PDF. No Workers needed —
|
|
4
|
+
// a scoped API token is enough.
|
|
5
|
+
//
|
|
6
|
+
// Endpoints (v4 API):
|
|
7
|
+
// POST /accounts/{ACCOUNT_ID}/browser-rendering/{screenshot|markdown|pdf}
|
|
8
|
+
//
|
|
9
|
+
// Security: URL strictness first — only public http(s), localhost / private
|
|
10
|
+
// / reserved IPs / IPv6 literals / userinfo are rejected (SSRF guard).
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "fs";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
|
|
16
|
+
const homedirPath = (): string => homedir();
|
|
17
|
+
const joinPath = (...parts: string[]): string => join(...parts);
|
|
18
|
+
|
|
19
|
+
export type Action = "markdown" | "screenshot" | "pdf";
|
|
20
|
+
|
|
21
|
+
export type ApiResult =
|
|
22
|
+
| { ok: true; action: Action; content: string | Uint8Array }
|
|
23
|
+
| { ok: false; action?: Action; content?: undefined; error: string };
|
|
24
|
+
|
|
25
|
+
const MAX_URL_LENGTH = 2048;
|
|
26
|
+
const DEFAULT_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
27
|
+
|
|
28
|
+
function isPrivateIp(ip: string): boolean {
|
|
29
|
+
if (ip === "::1" || ip === "::" || ip === "0.0.0.0") return true;
|
|
30
|
+
if (ip.startsWith("::ffff:")) ip = ip.slice(7);
|
|
31
|
+
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return false; // hostname, not an IP
|
|
32
|
+
const parts = ip.split(".").map(Number);
|
|
33
|
+
if (parts.some((n) => n < 0 || n > 255)) return true;
|
|
34
|
+
const [a, b] = parts;
|
|
35
|
+
return (
|
|
36
|
+
a === 10 ||
|
|
37
|
+
a === 127 ||
|
|
38
|
+
(a === 169 && b === 254) ||
|
|
39
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
40
|
+
(a === 192 && b === 168) ||
|
|
41
|
+
(a === 100 && b >= 64 && b <= 127) || // CGNAT
|
|
42
|
+
(a === 192 && b === 0) || // 192.0.0.0/24 (incl. IETF)
|
|
43
|
+
(a === 198 && (b === 18 || b === 19)) || // benchmarking
|
|
44
|
+
a >= 224 // multicast/reserved
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Reject non-http(s), oversized, userinfo, or SSRF-prone URLs. */
|
|
49
|
+
export function assertSafeUrl(raw: string): string {
|
|
50
|
+
if (!raw || raw.length > MAX_URL_LENGTH) throw new Error("url too long or empty");
|
|
51
|
+
let u: URL;
|
|
52
|
+
try {
|
|
53
|
+
u = new URL(raw);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error("invalid URL");
|
|
56
|
+
}
|
|
57
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("only http/https allowed");
|
|
58
|
+
if (u.username || u.password) throw new Error("userinfo not allowed");
|
|
59
|
+
if (u.hostname === "localhost") throw new Error("localhost not allowed");
|
|
60
|
+
const host = u.hostname.toLowerCase();
|
|
61
|
+
if (/\.local$/.test(host)) throw new Error("local hostnames not allowed");
|
|
62
|
+
const ipCandidate = host.replace(/^\[|\]$/g, "");
|
|
63
|
+
if (ipCandidate.includes(":")) throw new Error("IPv6 literal not allowed");
|
|
64
|
+
if (isPrivateIp(ipCandidate)) throw new Error("private/reserved IP not allowed");
|
|
65
|
+
return u.toString();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface BrowserRunConfig {
|
|
69
|
+
apiToken: string;
|
|
70
|
+
accountId: string;
|
|
71
|
+
apiBase?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read credentials from ~/.pi/agent/cloudflare-browser-run.json.
|
|
75
|
+
* Keys use the `a_b_c` form: cf_api_token / cf_account_id / cf_api_base.
|
|
76
|
+
* `configPath` is injectable for tests. Returns a config or a setup-hint
|
|
77
|
+
* error. */
|
|
78
|
+
export function loadConfig(configPath?: string): BrowserRunConfig | { error: string } {
|
|
79
|
+
let file: Record<string, string> = {};
|
|
80
|
+
const p = configPath ?? joinPath(homedirPath(), ".pi", "agent", "cloudflare-browser-run.json");
|
|
81
|
+
try {
|
|
82
|
+
file = JSON.parse(readFileSync(p, "utf8")) as Record<string, string>;
|
|
83
|
+
} catch {
|
|
84
|
+
// no config file — nothing to read
|
|
85
|
+
}
|
|
86
|
+
const apiToken = file["cf_api_token"] ?? file.apiToken;
|
|
87
|
+
const accountId = file["cf_account_id"] ?? file.accountId;
|
|
88
|
+
if (!apiToken) {
|
|
89
|
+
return {
|
|
90
|
+
error:
|
|
91
|
+
"cf_api_token missing in ~/.pi/agent/cloudflare-browser-run.json — " +
|
|
92
|
+
"create a token with Browser Rendering:Edit permission",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (!accountId) {
|
|
96
|
+
return {
|
|
97
|
+
error:
|
|
98
|
+
"cf_account_id missing in ~/.pi/agent/cloudflare-browser-run.json — " +
|
|
99
|
+
"your Cloudflare account id",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
apiToken,
|
|
104
|
+
accountId,
|
|
105
|
+
apiBase: file["cf_api_base"] ?? file.apiBase ?? DEFAULT_API_BASE,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function browserRunAction(config: BrowserRunConfig, action: Action, rawUrl: string): Promise<ApiResult> {
|
|
110
|
+
try {
|
|
111
|
+
const url = assertSafeUrl(rawUrl);
|
|
112
|
+
const base = (config.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
113
|
+
const endpoint = `${base}/accounts/${encodeURIComponent(config.accountId)}/browser-rendering/${action}`;
|
|
114
|
+
const res = await fetch(endpoint, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: {
|
|
117
|
+
Authorization: `Bearer ${config.apiToken}`,
|
|
118
|
+
"Content-Type": "application/json",
|
|
119
|
+
"user-agent":
|
|
120
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
121
|
+
},
|
|
122
|
+
body: JSON.stringify({ url }),
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
const body = await res.text();
|
|
126
|
+
return { ok: false, error: `Browser Run ${action} failed (HTTP ${res.status}): ${body.slice(0, 200)}` };
|
|
127
|
+
}
|
|
128
|
+
if (action === "markdown") {
|
|
129
|
+
return { ok: true, action, content: await res.text() };
|
|
130
|
+
}
|
|
131
|
+
return { ok: true, action, content: new Uint8Array(await res.arrayBuffer()) };
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// pi-cloudflare-browser-run — Pi extension registering web-browsing tools
|
|
2
|
+
// backed by Cloudflare Browser Run Quick Actions.
|
|
3
|
+
//
|
|
4
|
+
// Tools:
|
|
5
|
+
// browse(url, action?) read a page as clean markdown (default)
|
|
6
|
+
// screenshot(url) save a PNG of the page, returns local path
|
|
7
|
+
// pdf(url) save a PDF of the page, returns local path
|
|
8
|
+
//
|
|
9
|
+
// Auth: CLOUDFLARE_API_TOKEN (Browser Rendering:Edit) + CLOUDFLARE_ACCOUNT_ID
|
|
10
|
+
// env vars. No Workers, no proxy — direct v4 REST calls.
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
15
|
+
import { tmpdir } from "os";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { browserRunAction, loadConfig, type Action } from "./api.js";
|
|
18
|
+
|
|
19
|
+
const urlSchema = Type.Object({
|
|
20
|
+
url: Type.String({ minLength: 1, description: "Public http(s) URL, e.g. https://example.com" }),
|
|
21
|
+
});
|
|
22
|
+
const browseSchema = Type.Object({
|
|
23
|
+
url: Type.String({ minLength: 1, description: "Public http(s) URL, e.g. https://example.com" }),
|
|
24
|
+
action: Type.Optional(
|
|
25
|
+
Type.Union(
|
|
26
|
+
[
|
|
27
|
+
Type.Literal("markdown"),
|
|
28
|
+
Type.Literal("screenshot"),
|
|
29
|
+
Type.Literal("pdf"),
|
|
30
|
+
],
|
|
31
|
+
{ description: "What to extract (default markdown)" },
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const ACTION_LABEL: Record<Action, string> = {
|
|
37
|
+
markdown: "markdown text",
|
|
38
|
+
screenshot: "PNG",
|
|
39
|
+
pdf: "PDF",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function toolResult(text: string) {
|
|
43
|
+
return { content: [{ type: "text" as const, text }], details: {} };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function saveToTemp(action: Action, data: Uint8Array): string {
|
|
47
|
+
const dir = join(tmpdir(), "pi-cloudflare-browser-run");
|
|
48
|
+
mkdirSync(dir, { recursive: true });
|
|
49
|
+
const ext = action === "screenshot" ? "png" : "pdf";
|
|
50
|
+
const file = join(dir, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`);
|
|
51
|
+
writeFileSync(file, data);
|
|
52
|
+
return file;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function (pi: ExtensionAPI) {
|
|
56
|
+
const config = loadConfig();
|
|
57
|
+
|
|
58
|
+
pi.registerTool({
|
|
59
|
+
name: "browse",
|
|
60
|
+
label: "Browse",
|
|
61
|
+
description:
|
|
62
|
+
"Fetch a public web page in a headless Chrome (Cloudflare Browser Run) " +
|
|
63
|
+
"and return clean markdown text. Use for ANY web access: reading articles, " +
|
|
64
|
+
"checking live sites, extracting text from JS-rendered pages.",
|
|
65
|
+
promptSnippet: "browse a public URL (markdown / screenshot / pdf)",
|
|
66
|
+
promptGuidelines: [
|
|
67
|
+
"Prefer browse over guessing for anything that changed recently or lives on the web.",
|
|
68
|
+
"browse only accepts public http(s) URLs; private/localhost addresses are rejected.",
|
|
69
|
+
],
|
|
70
|
+
parameters: browseSchema,
|
|
71
|
+
executionMode: "sequential",
|
|
72
|
+
async execute(_toolCallId, params) {
|
|
73
|
+
const action: Action = (params.action ?? "markdown") as Action;
|
|
74
|
+
const cfg = typeof config === "object" && "apiToken" in config ? config : null;
|
|
75
|
+
if (!cfg) return toolResult(`browse unavailable: ${(config as { error: string }).error}`);
|
|
76
|
+
const r = await browserRunAction(cfg, action, params.url);
|
|
77
|
+
if (!r.ok) return toolResult(`browse failed: ${"error" in r ? r.error : "unknown"}`);
|
|
78
|
+
if (action === "markdown") {
|
|
79
|
+
const text = String(r.content).slice(0, 100_000);
|
|
80
|
+
return toolResult(text || "(page returned no readable text)");
|
|
81
|
+
}
|
|
82
|
+
const file = saveToTemp(action, r.content as Uint8Array);
|
|
83
|
+
return toolResult(`${ACTION_LABEL[action]} saved to ${file}`);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
pi.registerTool({
|
|
88
|
+
name: "screenshot",
|
|
89
|
+
label: "Screenshot",
|
|
90
|
+
description:
|
|
91
|
+
"Take a screenshot of a public web page in headless Chrome (Cloudflare " +
|
|
92
|
+
"Browser Run) and save it locally as PNG. Returns the local file path.",
|
|
93
|
+
promptSnippet: "screenshot a public URL to PNG",
|
|
94
|
+
parameters: urlSchema,
|
|
95
|
+
executionMode: "sequential",
|
|
96
|
+
async execute(_toolCallId, params) {
|
|
97
|
+
const cfg = typeof config === "object" && "apiToken" in config ? config : null;
|
|
98
|
+
if (!cfg) return toolResult(`screenshot unavailable: ${(config as { error: string }).error}`);
|
|
99
|
+
const r = await browserRunAction(cfg, "screenshot", params.url);
|
|
100
|
+
if (!r.ok) return toolResult(`screenshot failed: ${"error" in r ? r.error : "unknown"}`);
|
|
101
|
+
const file = saveToTemp("screenshot", r.content as Uint8Array);
|
|
102
|
+
return toolResult(`screenshot saved to ${file}`);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
pi.registerTool({
|
|
107
|
+
name: "pdf",
|
|
108
|
+
label: "Save as PDF",
|
|
109
|
+
description:
|
|
110
|
+
"Render a public web page to PDF in headless Chrome (Cloudflare Browser " +
|
|
111
|
+
"Run) and save it locally. Returns the local file path.",
|
|
112
|
+
promptSnippet: "save a public URL as PDF",
|
|
113
|
+
parameters: urlSchema,
|
|
114
|
+
executionMode: "sequential",
|
|
115
|
+
async execute(_toolCallId, params) {
|
|
116
|
+
const cfg = typeof config === "object" && "apiToken" in config ? config : null;
|
|
117
|
+
if (!cfg) return toolResult(`pdf unavailable: ${(config as { error: string }).error}`);
|
|
118
|
+
const r = await browserRunAction(cfg, "pdf", params.url);
|
|
119
|
+
if (!r.ok) return toolResult(`pdf failed: ${"error" in r ? r.error : "unknown"}`);
|
|
120
|
+
const file = saveToTemp("pdf", r.content as Uint8Array);
|
|
121
|
+
return toolResult(`pdf saved to ${file}`);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|