@prompteryx/sdk 0.4.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.
@@ -0,0 +1,385 @@
1
+ /**
2
+ * `px.autopilot.*` — autonomous browser agent.
3
+ *
4
+ * **Autopilot** is the autonomous, multi-step surface. You hand it
5
+ * a goal in plain English; it drives the browser end-to-end and
6
+ * returns a step-by-step trace. Counterpart to **Copilot** which
7
+ * helps with one step at a time (see ../page.ts).
8
+ *
9
+ * Built on the existing AI Browser Agent runtime. Every option the
10
+ * in-app UI exposes — model, max steps, system-prompt override,
11
+ * tool restrictions, viewport, region, proxy, CAPTCHA, recording —
12
+ * is available via the SDK. New options added to the runtime ride
13
+ * through `passthrough` without an SDK release.
14
+ *
15
+ * **Action caching superpower** — set `saveAsWorkflow: true` and the
16
+ * autopilot's discovered action sequence is captured as a permanent
17
+ * Visual Studio workflow you can replay for free forever. The
18
+ * response includes `savedWorkflowId`. Subsequent calls to
19
+ * `px.workflows.run(savedWorkflowId)` cost no AI Credits and benefit
20
+ * from multi-option-selector resilience to UI changes.
21
+ */
22
+
23
+ import { ParseError, PrompteryxError } from '../errors'
24
+ import type { HttpClient } from '../client'
25
+ import type {
26
+ AutopilotRunOptions,
27
+ AutopilotRunResult,
28
+ AutopilotStep,
29
+ } from '../types'
30
+
31
+ export class AutopilotResource {
32
+ constructor(private readonly http: HttpClient) {}
33
+
34
+ /**
35
+ * Run the autopilot. Blocks until the task finishes (success, step
36
+ * limit, or error). Returns the trace plus optionally the saved
37
+ * workflow id.
38
+ *
39
+ * ```ts
40
+ * const result = await px.autopilot.run({
41
+ * goal: 'Apply for the Senior Engineer role at OpenAI',
42
+ * startUrl: 'https://openai.com/careers',
43
+ * maxSteps: 40,
44
+ * saveAsWorkflow: true, // Replay-forever, zero AI cost
45
+ * session: { useProxy: true, useCaptcha: true },
46
+ * })
47
+ * if (result.savedWorkflowId) {
48
+ * console.log('Saved as workflow:', result.savedWorkflowId)
49
+ * // Run it later for free:
50
+ * await px.workflows.run(result.savedWorkflowId)
51
+ * }
52
+ * ```
53
+ */
54
+ async run(opts: AutopilotRunOptions): Promise<AutopilotRunResult> {
55
+ if (opts.target === 'local') {
56
+ // Local Chrome autopilot (v0.3+): drive the user's OWN Chrome via the
57
+ // Prompteryx desktop app on THIS machine. Zero cloud-browser minutes.
58
+ // The SDK talks directly to the local runner (localhost:61337) — the
59
+ // cloud API can't reach the user's machine — so requirements are: the
60
+ // desktop app RUNNING + SIGNED IN, and your code on the same machine.
61
+ return this.runLocal(opts)
62
+ }
63
+ // Multi-agent swarm runs server-side and returns a MERGED result in one
64
+ // response — keep it on the single-request path (unchanged).
65
+ if (opts.agents && opts.agents > 1) {
66
+ return this.runSwarm(opts)
67
+ }
68
+ // Single cloud agent: ASYNC start → run(poll) so a LONG task (e.g. filling a
69
+ // form once per CSV row) never runs in one synchronous request — which would
70
+ // exceed the infra GATEWAY timeout and hand the caller an HTML error page that
71
+ // res.json() chokes on. Each request stays short; we loop until the job is done.
72
+ return this.runAsync(opts)
73
+ }
74
+
75
+ /** The task params shared by the sync, swarm, and async request bodies. */
76
+ private cloudTaskBody(opts: AutopilotRunOptions): Record<string, unknown> {
77
+ return {
78
+ instruction: opts.goal,
79
+ startUrl: opts.startUrl,
80
+ maxSteps: opts.maxSteps,
81
+ maxCredits: opts.maxCredits,
82
+ model: opts.model,
83
+ aiVision: opts.aiVision,
84
+ finalStepVision: opts.finalStepVision,
85
+ outputSchema: opts.outputSchema,
86
+ costSaving: opts.costSaving,
87
+ costSavingMaxBatch: opts.costSavingMaxBatch,
88
+ carefulBatching: opts.carefulBatching,
89
+ saveAsWorkflow: opts.saveAsWorkflow,
90
+ savedWorkflowName: opts.savedWorkflowName,
91
+ sessionId: opts.sessionId,
92
+ systemPromptOverride: opts.systemPromptOverride,
93
+ allowedTools: opts.allowedTools,
94
+ viewport: opts.viewport,
95
+ session: opts.session,
96
+ // Settings parity (2026-07-15) — mirror the AI Browser Agent settings dialog.
97
+ safetyConsent: opts.safetyConsent,
98
+ confirmUnclear: opts.confirmUnclear,
99
+ enableContextCompression: opts.enableContextCompression,
100
+ compressionThreshold: opts.compressionThreshold,
101
+ enableSessionReset: opts.enableSessionReset,
102
+ sessionResetThreshold: opts.sessionResetThreshold,
103
+ ...opts.passthrough,
104
+ }
105
+ }
106
+
107
+ /** Multi-agent swarm — one synchronous request; the server merges all lanes. */
108
+ private async runSwarm(opts: AutopilotRunOptions): Promise<AutopilotRunResult> {
109
+ const env = await this.http.request<{ data?: unknown }>('/api/v1/ai-browser/execute', {
110
+ method: 'POST',
111
+ timeoutMs: opts.timeoutMs ?? 10 * 60_000,
112
+ body: {
113
+ ...this.cloudTaskBody(opts),
114
+ agents: opts.agents,
115
+ collaborate: opts.collaborate,
116
+ attachmentContext: opts.attachmentContext,
117
+ maxRunCredits: opts.maxRunCredits,
118
+ },
119
+ })
120
+ return this.mapResult(env?.data ?? env)
121
+ }
122
+
123
+ /**
124
+ * Async single-agent run: POST { mode:'start' } to set up the job (returns a
125
+ * jobId immediately), then POST { mode:'run' } in a loop — each advances the job
126
+ * for up to ~230s server-side and returns the current status — until the job is
127
+ * terminal or `opts.timeoutMs` elapses. No single request is long, so the gateway
128
+ * timeout is never hit. Same `AutopilotRunResult` shape as before.
129
+ */
130
+ private async runAsync(opts: AutopilotRunOptions): Promise<AutopilotRunResult> {
131
+ const timeoutMs = opts.timeoutMs ?? 10 * 60_000
132
+ const deadline = Date.now() + timeoutMs
133
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
134
+
135
+ // 1) START — SETUP only. Short request; returns { jobId, sessionId }.
136
+ const startEnv = await this.http.request<{ data?: any }>('/api/v1/ai-browser/execute', {
137
+ method: 'POST',
138
+ timeoutMs: 60_000,
139
+ body: { ...this.cloudTaskBody(opts), mode: 'start' },
140
+ })
141
+ const jobId: string | undefined = startEnv?.data?.jobId
142
+ if (!jobId) {
143
+ // Back-compat: an OLDER server without async mode ran the whole task
144
+ // synchronously for mode:'start', so the response IS a full result. Return it.
145
+ return this.mapResult(startEnv?.data ?? startEnv)
146
+ }
147
+
148
+ // 2) RUN — advance in ≤230s chunks until terminal (or the SDK timeout). The
149
+ // chunk itself is the wait; a transport blip on a chunk is safe to retry.
150
+ let view: any = null
151
+ while (Date.now() < deadline) {
152
+ const runEnv = await this.http.request<{ data?: any }>('/api/v1/ai-browser/execute', {
153
+ method: 'POST',
154
+ timeoutMs: 250_000,
155
+ retry: true,
156
+ body: { jobId, mode: 'run', maxSteps: opts.maxSteps },
157
+ })
158
+ view = runEnv?.data ?? null
159
+ if (!view || view.terminal || view.status !== 'running') break
160
+ await sleep(500)
161
+ }
162
+
163
+ // 3) If we bailed on the SDK timeout while still running, one status read gives
164
+ // the latest known state (and triggers server finalize if it's since ended).
165
+ if (view && view.status === 'running') {
166
+ try {
167
+ const statusEnv = await this.http.request<{ data?: any }>('/api/v1/ai-browser/execute', {
168
+ query: { jobId },
169
+ timeoutMs: 30_000,
170
+ })
171
+ if (statusEnv?.data) view = statusEnv.data
172
+ } catch { /* keep the last view */ }
173
+ }
174
+
175
+ return this.mapResult(view)
176
+ }
177
+
178
+ /** Map an execute-endpoint payload (sync result, swarm result, or async status
179
+ * view — they share finalAnswer / steps / usage / savedWorkflowId / status) into
180
+ * the public AutopilotRunResult shape. */
181
+ private mapResult(data: any): AutopilotRunResult {
182
+ const d = data || {}
183
+ const status: string | undefined = d.status
184
+ const success = status === 'completed' || status === 'max_steps_reached'
185
+ const steps: AutopilotStep[] = Array.isArray(d.steps)
186
+ ? d.steps.map((s: any, i: number) => ({
187
+ step: i + 1,
188
+ action: typeof s?.action === 'string' ? s.action : (s?.action?.name || 'action'),
189
+ result: s?.details ?? s?.result,
190
+ }))
191
+ : []
192
+ return {
193
+ success,
194
+ finalAnswer: d.finalAnswer,
195
+ steps,
196
+ savedWorkflowId: d.savedWorkflowId,
197
+ usage: d.usage,
198
+ swarm: d.swarm,
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Local Chrome autopilot — talks DIRECTLY to the Prompteryx desktop app on
204
+ * this machine (localhost:61337): opens your local Chrome, runs the agent
205
+ * loop there, polls until done. Same options as `run` (model, aiVision
206
+ * preset, maxSteps, maxCredits, costSaving). Credits still apply; cloud
207
+ * minutes do not. The tab stays open after the run for follow-ups.
208
+ */
209
+ private async runLocal(opts: AutopilotRunOptions): Promise<AutopilotRunResult> {
210
+ const fetchImpl: typeof fetch = (globalThis as any).fetch
211
+ if (!fetchImpl) {
212
+ throw new Error('[Prompteryx SDK] Local target needs a global fetch (Node 18+ / browser).')
213
+ }
214
+ const base = (opts.runnerUrl || 'http://localhost:61337').replace(/\/$/, '')
215
+ const sessionId = `sdk-local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
216
+ const goal = opts.startUrl
217
+ ? `First, go to ${opts.startUrl}. Then: ${opts.goal}`
218
+ : opts.goal
219
+
220
+ const post = async (path: string, body: unknown) => {
221
+ let r: Response
222
+ try {
223
+ r = await fetchImpl(`${base}${path}`, {
224
+ method: 'POST',
225
+ headers: { 'Content-Type': 'application/json' },
226
+ body: JSON.stringify(body),
227
+ })
228
+ } catch (e) {
229
+ throw new Error(
230
+ `[Prompteryx SDK] Couldn't reach the desktop app at ${base}. ` +
231
+ `Make sure the Prompteryx desktop app is running and signed in on this machine. (${(e as Error).message})`,
232
+ )
233
+ }
234
+ if (!r.ok) {
235
+ let msg = `${path} failed (${r.status})`
236
+ try { const j = await r.json(); if ((j as any)?.error) msg = (j as any).error } catch { /* */ }
237
+ throw new Error(`[Prompteryx SDK] ${msg}`)
238
+ }
239
+ return r.json().catch(() => ({}))
240
+ }
241
+
242
+ // 1) Open the local Chrome + create the agent session.
243
+ await post('/launch-gemini-browser', {
244
+ sessionId,
245
+ profileId: (opts.session as any)?.profileId,
246
+ enableRecording: false,
247
+ })
248
+
249
+ // 2) Start the agent loop with the same knobs as the cloud path.
250
+ await post('/local-agent/start', {
251
+ sessionId,
252
+ task: goal,
253
+ model: opts.model,
254
+ aiVision: opts.aiVision, // preset slug — resolved app-side
255
+ maxTurns: opts.maxSteps,
256
+ maxCredits: opts.maxCredits,
257
+ costSaving: (opts.passthrough as any)?.costSaving === true,
258
+ costSavingMaxBatch: (opts.passthrough as any)?.costSavingMaxBatch,
259
+ })
260
+
261
+ // 3) Poll state until terminal (or the SDK timeout).
262
+ const deadline = Date.now() + (opts.timeoutMs ?? 10 * 60_000)
263
+ const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms))
264
+ let state: any = null
265
+ while (Date.now() < deadline) {
266
+ await sleep(1200)
267
+ const r = await fetchImpl(`${base}/local-agent/state/${encodeURIComponent(sessionId)}`).catch(() => null)
268
+ if (!r || !r.ok) continue
269
+ state = await r.json().catch(() => null)
270
+ if (!state || state.found === false) continue
271
+ if (state.status && state.status !== 'running') break
272
+ }
273
+
274
+ const status = state?.status
275
+ return {
276
+ success: status === 'done' || status === 'awaiting_input',
277
+ finalAnswer: state?.answer,
278
+ steps: (state?.steps || []).map((s: any, i: number) => ({
279
+ step: i + 1,
280
+ action: s?.name || s?.action?.name || 'action',
281
+ result: s?.args || s?.action?.args,
282
+ })),
283
+ usage: {
284
+ aiCredits: Math.max(0, Math.round((state?.costUSD || 0) / 0.01)),
285
+ tokensIn: state?.inTokens || 0,
286
+ tokensOut: state?.outTokens || 0,
287
+ costUSD: state?.costUSD || 0,
288
+ turns: state?.turns || 0,
289
+ },
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Ask a running async job to stop at its next step boundary
295
+ * (`POST { jobId, mode: 'stop' }`). Use it to wind down a job you
296
+ * started via the raw API (or a run you're abandoning) instead of
297
+ * leaving it stepping against a dead browser session until the step
298
+ * cap — an abandoned job burns a model call + timeout per step.
299
+ *
300
+ * ```ts
301
+ * const { stopRequested, status } = await px.autopilot.stop(jobId)
302
+ * ```
303
+ */
304
+ async stop(jobId: string): Promise<{ jobId: string; stopRequested: boolean; status?: string }> {
305
+ const env = await this.http.request<{ data?: any }>('/api/v1/ai-browser/execute', {
306
+ method: 'POST',
307
+ timeoutMs: 30_000,
308
+ body: { jobId, mode: 'stop' },
309
+ })
310
+ const d = env?.data ?? env ?? {}
311
+ return { jobId: d.jobId ?? jobId, stopRequested: d.stopRequested === true, status: d.status }
312
+ }
313
+
314
+ /**
315
+ * Run the autopilot over the keep-alive stream endpoint and yield the
316
+ * step trace. Ends with a `{ step: -1, action: 'done' }` sentinel whose
317
+ * `result` field carries the full `AutopilotRunResult`.
318
+ *
319
+ * ```ts
320
+ * for await (const step of px.autopilot.stream({ goal: 'Buy a ticket' })) {
321
+ * console.log('Step', step.step, '→', step.action)
322
+ * if (step.action === 'done') break
323
+ * }
324
+ * ```
325
+ *
326
+ * PROTOCOL (matches /api/v1/ai-browser/execute-stream — it is NOT SSE):
327
+ * the server emits a 1-space heartbeat every 15s while the run executes,
328
+ * then the complete execute-route JSON as the final chunk, i.e. the body
329
+ * is `<heartbeats>\n<json>`. The heartbeats exist to defeat the ~300s
330
+ * infra idle timeout on long synchronous runs; per-step live events are
331
+ * not available on this route, so steps arrive together when the run
332
+ * finishes. Prefer `run()` unless you specifically want the keep-alive
333
+ * transport for a long single-request run.
334
+ */
335
+ async *stream(
336
+ opts: AutopilotRunOptions & { signal?: AbortSignal },
337
+ ): AsyncGenerator<AutopilotStep, void, void> {
338
+ if (opts.target === 'local') {
339
+ // Local streaming isn't wired — the local runner has no step emitter.
340
+ // Use the blocking `run({ target: 'local' })` (its result includes
341
+ // the full step list) for local Chrome today.
342
+ throw new Error(
343
+ '[Prompteryx SDK] Streaming is cloud-only. For local Chrome use ' +
344
+ 'autopilot.run({ target: "local" }) — its result contains all steps.',
345
+ )
346
+ }
347
+ const res = await this.http.rawRequest('/api/v1/ai-browser/execute-stream', {
348
+ method: 'POST',
349
+ timeoutMs: opts.timeoutMs ?? 15 * 60_000,
350
+ body: this.cloudTaskBody(opts),
351
+ signal: opts.signal,
352
+ })
353
+ // Accumulate the whole body: heartbeat spaces, then '\n' + the JSON.
354
+ let text = ''
355
+ if (res.body) {
356
+ const reader = res.body.getReader()
357
+ const decoder = new TextDecoder()
358
+ while (true) {
359
+ const { value, done } = await reader.read()
360
+ if (done) break
361
+ if (value) text += decoder.decode(value, { stream: true })
362
+ }
363
+ text += decoder.decode()
364
+ } else {
365
+ text = await res.text()
366
+ }
367
+ // Strip the heartbeats — the JSON is always the last (and only) content.
368
+ const payload = text.trim()
369
+ let parsed: any
370
+ try {
371
+ parsed = JSON.parse(payload)
372
+ } catch {
373
+ throw new ParseError('execute-stream returned a non-JSON payload', {
374
+ raw: payload.slice(0, 500),
375
+ })
376
+ }
377
+ if (parsed && parsed.success === false) {
378
+ const msg = parsed?.error?.message ?? parsed?.error ?? 'AI Browser Agent run failed'
379
+ throw new PrompteryxError(String(msg), { code: parsed?.error?.code, raw: parsed })
380
+ }
381
+ const result = this.mapResult(parsed?.data ?? parsed)
382
+ for (const step of result.steps) yield step
383
+ yield { step: -1, action: 'done', result }
384
+ }
385
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `px.cloudBrowser.*` — sessions, one-shot fetch, search.
3
+ *
4
+ * The most-used path: `sessions.create()` returns a `connectUrl` you
5
+ * pass to Playwright's `chromium.connectOverCDP(connectUrl)`. Your own
6
+ * Playwright code drives the browser from there; we handle the
7
+ * infrastructure (residential proxies, recording, persistence).
8
+ *
9
+ * ⚠️ KEY FAMILY (verified live 2026-09-03): every /api/v1/cloud-browser/*
10
+ * route authenticates with a CLOUD BROWSER key (`pcb_live_…`) sent as
11
+ * `x-api-key` — NOT the platform `px_live_…` Bearer key the rest of the
12
+ * SDK uses. Pass it as `new Prompteryx({ apiKey, cloudBrowserKey })`;
13
+ * calls throw a descriptive AuthError when it's missing.
14
+ */
15
+
16
+ import type { HttpClient, RequestOptions } from '../client'
17
+ import { AuthError } from '../errors'
18
+ import type {
19
+ CloudFetchOptions,
20
+ CloudFetchResult,
21
+ CloudSearchResult,
22
+ CloudSession,
23
+ CloudSessionSummary,
24
+ CreateSessionOptions,
25
+ } from '../types'
26
+
27
+ const MISSING_CB_KEY_MESSAGE =
28
+ 'px.cloudBrowser.* uses a Cloud Browser API key (pcb_live_…), which is a ' +
29
+ 'separate key family from the platform px_live_… key. Create one under ' +
30
+ 'Cloud Platform → API Keys and pass it as ' +
31
+ 'new Prompteryx({ apiKey, cloudBrowserKey }).'
32
+
33
+ /** Sub-resource: cloud browser sessions. */
34
+ export class SessionsResource {
35
+ constructor(private readonly http: HttpClient) {}
36
+
37
+ /** Headers for the pcb_live_ key family (throws a clear error if absent). */
38
+ private cbAuth(): Record<string, string> {
39
+ const key = this.http.cloudBrowserKey
40
+ if (!key) throw new AuthError(MISSING_CB_KEY_MESSAGE)
41
+ return { 'x-api-key': key }
42
+ }
43
+
44
+ private cbRequest<T>(path: string, opts: RequestOptions = {}): Promise<T> {
45
+ return this.http.request<T>(path, {
46
+ ...opts,
47
+ headers: { ...this.cbAuth(), ...(opts.headers ?? {}) },
48
+ })
49
+ }
50
+
51
+ /** Create a new browser session.
52
+ *
53
+ * Cloud (default):
54
+ * ```ts
55
+ * const s = await px.cloudBrowser.sessions.create()
56
+ * // s.connectUrl → Prompteryx Cloud CDP. Bills cloud-browser minutes.
57
+ * ```
58
+ *
59
+ * Local — uses YOUR machine's Chrome via the Prompteryx plugin +
60
+ * Electron runner. ZERO cloud-browser minutes. Requires the plugin
61
+ * to be running on the same machine as the SDK consumer; the call
62
+ * short-circuits to localhost and never reaches the API.
63
+ * ```ts
64
+ * const s = await px.cloudBrowser.sessions.create({ target: 'local' })
65
+ * // s.connectUrl → http://localhost:9222 (your Chrome's debug port)
66
+ * ```
67
+ *
68
+ * When `target: 'local'` is set, cloud-only fields (recordSession,
69
+ * proxy, profileId) are ignored — you're driving your own Chrome
70
+ * with whatever cookies/extensions you've already installed.
71
+ */
72
+ async create(opts: CreateSessionOptions = {}): Promise<CloudSession> {
73
+ if (opts.target === 'local') {
74
+ // Local mode short-circuit. We don't hit the API at all — the
75
+ // session is just a thin wrapper over the user's local Chrome's
76
+ // CDP endpoint (managed by the Prompteryx plugin). Saves cloud
77
+ // minutes AND avoids a round-trip. Failure to connect is the
78
+ // consumer's problem at .connectOverCDP() time.
79
+ const localCdp = 'http://127.0.0.1:9222'
80
+ const localId = `local_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
81
+ return {
82
+ id: localId,
83
+ connectUrl: localCdp,
84
+ status: 'active',
85
+ startedAt: new Date().toISOString(),
86
+ recordSession: false,
87
+ }
88
+ }
89
+ // Wire names match the route (proxy/country), while the SDK options keep
90
+ // the friendlier useProxy/proxyLocation spelling.
91
+ return this.cbRequest<CloudSession>('/api/v1/cloud-browser/sessions', {
92
+ method: 'POST',
93
+ body: {
94
+ recordSession: opts.recordSession,
95
+ captureDownloads: opts.captureDownloads,
96
+ extensions: opts.extensions,
97
+ proxy: opts.useProxy,
98
+ country: opts.proxyLocation,
99
+ sessionTimeoutMinutes: opts.sessionTimeoutMinutes,
100
+ profileId: opts.profileId,
101
+ persistContext: opts.persistContext,
102
+ viewport: opts.viewport,
103
+ ...(opts.passthrough || {}),
104
+ },
105
+ })
106
+ }
107
+
108
+ /** Retrieve a session's history record (status/duration/recording flag).
109
+ * Note: this is durable history, not a live handle — it has no
110
+ * `connectUrl`. Keep the `create()` response for connecting. */
111
+ async get(sessionId: string): Promise<CloudSessionSummary> {
112
+ return this.cbRequest(
113
+ `/api/v1/cloud-browser/sessions/${encodeURIComponent(sessionId)}`,
114
+ )
115
+ }
116
+
117
+ /** List recent sessions for your account, newest first. */
118
+ async list(opts: { limit?: number } = {}): Promise<CloudSessionSummary[]> {
119
+ const res = await this.cbRequest<{ sessions: CloudSessionSummary[] }>(
120
+ '/api/v1/cloud-browser/sessions',
121
+ { query: { limit: opts.limit } },
122
+ )
123
+ return res.sessions ?? []
124
+ }
125
+
126
+ /** Close a session, finalising the recording (if any) + releasing the
127
+ * cloud-browser slot. Idempotent. No-op for local sessions
128
+ * (target: 'local') — those don't have a slot to release. */
129
+ async close(sessionId: string): Promise<{ ok: boolean; proxyMB?: number }> {
130
+ // Local sessions are pure client-side handles — nothing to release.
131
+ if (sessionId.startsWith('local_')) return { ok: true }
132
+ return this.cbRequest(
133
+ `/api/v1/cloud-browser/sessions/${encodeURIComponent(sessionId)}`,
134
+ { method: 'DELETE' },
135
+ )
136
+ }
137
+ }
138
+
139
+ export class CloudBrowserResource {
140
+ readonly sessions: SessionsResource
141
+
142
+ constructor(private readonly http: HttpClient) {
143
+ this.sessions = new SessionsResource(http)
144
+ }
145
+
146
+ private cbAuth(): Record<string, string> {
147
+ const key = this.http.cloudBrowserKey
148
+ if (!key) throw new AuthError(MISSING_CB_KEY_MESSAGE)
149
+ return { 'x-api-key': key }
150
+ }
151
+
152
+ /**
153
+ * One-shot fetch through the cloud browser. Spins up a short-lived
154
+ * session, loads the page in real Chromium (so JS-rendered sites work),
155
+ * extracts the content, and tears down. Use this when you only need ONE
156
+ * page and don't want to manage Playwright yourself.
157
+ *
158
+ * ```ts
159
+ * const page = await px.cloudBrowser.fetch({
160
+ * url: 'https://example.com/pricing',
161
+ * format: 'markdown', // 'text' (default) | 'markdown' | 'html' | 'links'
162
+ * waitForSelector: '.pricing-table', // for JS-rendered content
163
+ * selectors: ['.pricing-table .plan'], // deterministic CSS extraction
164
+ * })
165
+ * // page.content, page.extracted, page.title, page.finalUrl …
166
+ * ```
167
+ */
168
+ async fetch(opts: CloudFetchOptions): Promise<CloudFetchResult> {
169
+ return this.http.request('/api/v1/cloud-browser/fetch', {
170
+ method: 'POST',
171
+ body: opts,
172
+ headers: this.cbAuth(),
173
+ timeoutMs: Math.max(90_000, (opts.timeoutMs ?? 30_000) + 30_000),
174
+ })
175
+ }
176
+
177
+ /**
178
+ * Search the web through the cloud browser and get structured results
179
+ * (title/url/snippet). Runs the query against DuckDuckGo's server-rendered
180
+ * HTML endpoint in a real browser — there is no engine choice today.
181
+ */
182
+ async search(opts: {
183
+ query: string
184
+ /** Max results, 1–25. Default 10. */
185
+ limit?: number
186
+ /** Route through a residential proxy. */
187
+ proxy?: boolean
188
+ /** Proxy exit country (with `proxy: true`), e.g. 'us'. */
189
+ country?: string
190
+ }): Promise<CloudSearchResult[]> {
191
+ const res = await this.http.request<{ query: string; results: CloudSearchResult[] }>(
192
+ '/api/v1/cloud-browser/search',
193
+ { method: 'POST', body: opts, headers: this.cbAuth(), timeoutMs: 90_000 },
194
+ )
195
+ return res.results ?? []
196
+ }
197
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * ⚠️ NOT SHIPPED in @prompteryx/sdk 0.4.0 (2026-09-03) — not exported from
3
+ * src/index.ts. The /api/v1/connect-hub/* routes this resource targets do
4
+ * not exist on the live API (every call 404s). Verified against the 31
5
+ * live v1 routes in docs/API_SDK_DEVELOPER_PLAN_GOLIVE_AUDIT.md. Kept as
6
+ * source for a future release; do not re-export without re-verifying.
7
+ *
8
+ * `px.connectHub.*` — 2,800+ integrations.
9
+ *
10
+ * Browse + run actions from any of the Pipedream-backed apps
11
+ * Prompteryx exposes via Connect Hub (Gmail, Sheets, Slack,
12
+ * Notion, Stripe, Airtable, WordPress, …). The user's connected
13
+ * credentials are reused server-side; the SDK never sees them.
14
+ *
15
+ * Billing: 1 Connect Hub credit per action call, plus whatever the
16
+ * underlying integration costs through Pipedream. The platform's
17
+ * monthly Connect Hub allowance + top-up balance both apply
18
+ * identically to API + UI surfaces.
19
+ */
20
+
21
+ import type { HttpClient } from '../client'
22
+ import type {
23
+ ConnectHubActionSummary,
24
+ ConnectHubAppSummary,
25
+ RunConnectHubActionOptions,
26
+ } from '../types'
27
+
28
+ export class ConnectHubResource {
29
+ constructor(private readonly http: HttpClient) {}
30
+
31
+ /** List apps the platform supports. Cacheable client-side. */
32
+ async listApps(opts: { limit?: number; category?: string; search?: string } = {}): Promise<ConnectHubAppSummary[]> {
33
+ const res = await this.http.request<{ apps: ConnectHubAppSummary[] }>(
34
+ '/api/v1/connect-hub/apps',
35
+ { query: { limit: opts.limit, category: opts.category, q: opts.search } },
36
+ )
37
+ return res.apps ?? []
38
+ }
39
+
40
+ /** List the actions available on a specific app. */
41
+ async listActions(app: string): Promise<ConnectHubActionSummary[]> {
42
+ const res = await this.http.request<{ actions: ConnectHubActionSummary[] }>(
43
+ `/api/v1/connect-hub/apps/${encodeURIComponent(app)}/actions`,
44
+ )
45
+ return res.actions ?? []
46
+ }
47
+
48
+ /**
49
+ * Get the parameter schema for a specific action. Use this to
50
+ * build typed wrappers / validate inputs before calling `run`.
51
+ */
52
+ async getAction(app: string, action: string): Promise<ConnectHubActionSummary> {
53
+ return this.http.request(
54
+ `/api/v1/connect-hub/apps/${encodeURIComponent(app)}/actions/${encodeURIComponent(action)}`,
55
+ )
56
+ }
57
+
58
+ /**
59
+ * Execute a Connect Hub action.
60
+ *
61
+ * ```ts
62
+ * const sent = await px.connectHub.run({
63
+ * app: 'gmail',
64
+ * action: 'send_message',
65
+ * params: {
66
+ * to: 'hello@example.com',
67
+ * subject: 'Hi from Prompteryx',
68
+ * body: 'Hello!',
69
+ * },
70
+ * })
71
+ * ```
72
+ *
73
+ * Returns whatever the integration returns. Throws `QuotaError` if
74
+ * the user's Connect Hub allowance is exhausted (same gate the UI
75
+ * uses).
76
+ */
77
+ async run(opts: RunConnectHubActionOptions): Promise<unknown> {
78
+ return this.http.request('/api/v1/connect-hub/run', {
79
+ method: 'POST',
80
+ body: opts,
81
+ timeoutMs: 60_000,
82
+ })
83
+ }
84
+
85
+ /** List the user's connected accounts (one per app, possibly
86
+ * multiple per app for multi-account users). */
87
+ async listAccounts(): Promise<Array<{ id: string; app: string; label: string; addedAt: string }>> {
88
+ const res = await this.http.request<{ accounts: Array<{ id: string; app: string; label: string; addedAt: string }> }>(
89
+ '/api/v1/connect-hub/accounts',
90
+ )
91
+ return res.accounts ?? []
92
+ }
93
+ }