crawlforge-mcp-server 6.4.0 → 6.6.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.
Files changed (37) hide show
  1. package/CLAUDE.md +5 -5
  2. package/README.md +7 -6
  3. package/package.json +2 -1
  4. package/server.js +83 -15
  5. package/src/cli/commands/browser.js +77 -0
  6. package/src/cli/index.js +3 -1
  7. package/src/core/ActionExecutor.js +185 -7
  8. package/src/core/AuthManager.js +26 -0
  9. package/src/core/ChangeTracker.js +25 -8
  10. package/src/core/browser/SessionStore.js +331 -0
  11. package/src/core/browser/snapshot.js +346 -0
  12. package/src/core/llm/LLMManager.js +86 -6
  13. package/src/core/processing/PDFProcessor.js +3 -1
  14. package/src/server/fallbackHints.js +4 -0
  15. package/src/server/inlineThreshold.js +31 -1
  16. package/src/server/requestContext.js +26 -5
  17. package/src/server/toolFilter.js +2 -2
  18. package/src/server/transports/streamableHttp.js +38 -8
  19. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +9 -2
  20. package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +50 -4
  21. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +178 -0
  22. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +7 -4
  23. package/src/skills/agent-skills/crawlforge-getting-started/references/cli.md +6 -1
  24. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
  25. package/src/skills/installer.js +1 -1
  26. package/src/tools/advanced/BrowserSessionTool.js +476 -0
  27. package/src/tools/advanced/ScrapeWithActionsTool.js +10 -1
  28. package/src/tools/crawl/mapSite.js +81 -7
  29. package/src/tools/extract/extractEmbeddedState.js +18 -2
  30. package/src/tools/extract/extractStructured.js +4 -0
  31. package/src/tools/extract/processDocument.js +94 -1
  32. package/src/tools/scrape/_brandingExtractor.js +23 -5
  33. package/src/tools/scrape/unifiedScrape.js +8 -1
  34. package/src/tools/search/redditSearch.js +24 -17
  35. package/src/utils/hiddenContent.js +67 -2
  36. package/src/utils/redditHosts.js +123 -0
  37. package/src/utils/robotsGate.js +27 -3
@@ -27,6 +27,70 @@ function endsMidJson(text) {
27
27
  return !trimmed.endsWith('}') && !trimmed.endsWith(']');
28
28
  }
29
29
 
30
+ /**
31
+ * Recover the complete rows of a JSON response the model stopped writing.
32
+ *
33
+ * Walks the text tracking strings and the bracket stack, remembers the end of
34
+ * every complete element written directly inside an array, cuts there and
35
+ * closes what is still open. Returns null when no element completed, so a
36
+ * caller can still fall back. A schema asking for a table of rows routinely
37
+ * overruns a small model's output budget — the ECB key-rates table came back
38
+ * cut off at 1,800 and again at 3,600 tokens and the whole extraction failed,
39
+ * although dozens of rows were complete (R21, 2026-09-09).
40
+ *
41
+ * @param {string} text
42
+ * @returns {{ data: unknown, rows: number }|null}
43
+ */
44
+ export function salvageTruncatedJson(text) {
45
+ const src = text.trim();
46
+ if (!src.startsWith('{') && !src.startsWith('[')) return null;
47
+ const stack = [];
48
+ let inString = false;
49
+ let cut = -1;
50
+ for (let i = 0; i < src.length; i++) {
51
+ const ch = src[i];
52
+ if (inString) {
53
+ if (ch === '\\') i++;
54
+ else if (ch === '"') {
55
+ inString = false;
56
+ // A string written directly into an array is a complete element.
57
+ if (stack[stack.length - 1] === '[') cut = i + 1;
58
+ }
59
+ continue;
60
+ }
61
+ if (ch === '"') inString = true;
62
+ else if (ch === '{' || ch === '[') stack.push(ch);
63
+ else if (ch === '}' || ch === ']') {
64
+ stack.pop();
65
+ if (stack[stack.length - 1] === '[') cut = i + 1;
66
+ }
67
+ }
68
+ if (cut < 0) return null;
69
+ // Close everything still open at the cut, innermost first.
70
+ const open = [];
71
+ inString = false;
72
+ for (let i = 0; i < cut; i++) {
73
+ const ch = src[i];
74
+ if (inString) { if (ch === '\\') i++; else if (ch === '"') inString = false; continue; }
75
+ if (ch === '"') inString = true;
76
+ else if (ch === '{' || ch === '[') open.push(ch);
77
+ else if (ch === '}' || ch === ']') open.pop();
78
+ }
79
+ const closers = open.reverse().map((c) => (c === '{' ? '}' : ']')).join('');
80
+ let data;
81
+ try {
82
+ data = JSON.parse(src.slice(0, cut) + closers);
83
+ } catch {
84
+ return null;
85
+ }
86
+ let rows = 0;
87
+ (function count(v) {
88
+ if (Array.isArray(v)) { rows += v.length; v.forEach(count); }
89
+ else if (v && typeof v === 'object') Object.values(v).forEach(count);
90
+ })(data);
91
+ return rows > 0 ? { data, rows } : null;
92
+ }
93
+
30
94
  /**
31
95
  * LLM Manager
32
96
  * Manages multiple LLM providers and provides unified interface
@@ -987,12 +1051,25 @@ Extract the data and return valid JSON:`;
987
1051
  try {
988
1052
  parsed = JSON.parse(cleaned);
989
1053
  } catch (parseError) {
1054
+ if (!endsMidJson(cleaned)) throw parseError;
990
1055
  // Name the cause when the JSON simply stops: "unexpected end of
991
1056
  // input at position 2608" tells a caller nothing they can act on,
992
1057
  // whereas "cut off at the 1000-token limit" points at the schema.
993
- throw endsMidJson(cleaned)
994
- ? new Error(`model response was cut off at the ${budget}-token output limit (${cleaned.length} chars) — the schema asks for more rows than fit`)
995
- : parseError;
1058
+ const cutOff = `model response was cut off at the ${budget}-token output limit (${cleaned.length} chars) — the schema asks for more rows than fit`;
1059
+ // The doubled budget was the retry; when that is cut off too, the
1060
+ // rows it did complete are worth more than a fallback that has none.
1061
+ const salvaged = attempt > 0 ? salvageTruncatedJson(cleaned) : null;
1062
+ if (!salvaged) throw new Error(cutOff);
1063
+ const validation = this.validateAgainstSchema(salvaged.data, schema);
1064
+ this.logger.warn('LLM structured extraction salvaged a cut-off response', { budget, rows: salvaged.rows });
1065
+ return {
1066
+ data: salvaged.data,
1067
+ method: 'llm',
1068
+ valid: validation.valid,
1069
+ validationErrors: validation.errors,
1070
+ partial: true,
1071
+ warning: `${cutOff}; kept the ${salvaged.rows} complete row(s) it had written. Ask for fewer rows (a prompt naming the rows you need, or a narrower schema) to get the rest.`
1072
+ };
996
1073
  }
997
1074
 
998
1075
  const validation = this.validateAgainstSchema(parsed, schema);
@@ -1016,7 +1093,7 @@ Extract the data and return valid JSON:`;
1016
1093
  // Report which path produced the data. Callers previously labelled this
1017
1094
  // result "llm", so a failed LLM call was returned as a high-confidence
1018
1095
  // LLM extraction.
1019
- return { ...this.fallbackStructuredExtraction(content, schema), error: lastError.message };
1096
+ return { ...this.fallbackStructuredExtraction(content, schema, lastError.message), error: lastError.message };
1020
1097
  }
1021
1098
 
1022
1099
  /**
@@ -1036,7 +1113,7 @@ Extract the data and return valid JSON:`;
1036
1113
  /**
1037
1114
  * Fallback structured extraction without LLM — keyword/regex matching for primitives
1038
1115
  */
1039
- fallbackStructuredExtraction(content, schema) {
1116
+ fallbackStructuredExtraction(content, schema, reason = 'no LLM provider available') {
1040
1117
  const extracted = {};
1041
1118
  const properties = schema.properties || {};
1042
1119
 
@@ -1065,7 +1142,10 @@ Extract the data and return valid JSON:`;
1065
1142
  data: extracted,
1066
1143
  method: 'keyword_fallback',
1067
1144
  valid: false,
1068
- validationErrors: ['Used fallback extraction no LLM provider available']
1145
+ // Name the real reason: this fallback also runs after an LLM attempt
1146
+ // that failed, and "no LLM provider available" beside a working Ollama
1147
+ // sent a caller looking at the wrong thing (R21, 2026-09-09).
1148
+ validationErrors: [`Used fallback extraction — ${reason}`]
1069
1149
  };
1070
1150
  }
1071
1151
 
@@ -12,7 +12,9 @@ import { config } from '../../constants/config.js';
12
12
  import { identityHeaders } from '../../utils/fetchIdentity.js';
13
13
 
14
14
  const PDFProcessorSchema = z.object({
15
- source: z.string().min(1),
15
+ // A Buffer is the 'buffer' source: process_document hands over a body it
16
+ // has already fetched and sniffed as a PDF (R21, 2026-09-09).
17
+ source: z.union([z.string().min(1), z.instanceof(Buffer)]),
16
18
  sourceType: z.enum(['url', 'file', 'buffer']).default('url'),
17
19
  options: z.object({
18
20
  extractMetadata: z.boolean().default(true),
@@ -32,6 +32,7 @@ export const FALLBACK_HINTS = Object.freeze({
32
32
  extract_with_llm: 'If Ollama is unreachable pass provider:"openai" or "anthropic" with a key, or use extract_structured (CSS fallback needs no LLM).',
33
33
  list_ollama_models: 'Ollama is not reachable - use extract_with_llm with provider:"openai"/"anthropic", or extract_structured.',
34
34
  scrape_with_actions: 'Check the selector against scrape formats:["html"] output; for a one-shot render of a blocked page use stealth_mode operation:"scrape".',
35
+ browser_session: 'A session that expired or was closed cannot be reused - start again with operation:"open". If a ref missed, take another snapshot first: navigation invalidates refs. For a chain that needs no session, use scrape_with_actions.',
35
36
  deep_research: 'Use agent for a shorter answer, or search_web followed by scrape on the sources that matter.',
36
37
  scrape: 'After a 403/429/CAPTCHA/challenge page or an empty shell use stealth_mode operation:"scrape"; if the content needs a click or login use scrape_with_actions.',
37
38
  agent: 'Use deep_research for exhaustive sourcing, or search_web followed by scrape on the sources that matter.',
@@ -80,6 +81,9 @@ export function appendFallbackHint(toolName, result) {
80
81
  // plain text
81
82
  }
82
83
 
84
+ // An error that already names its next step — a reddit.com refusal points
85
+ // at reddit_search — keeps it: the tool's generic hint would contradict it.
86
+ if (parsed?.next_step || /\bNext step:/.test(first.text)) return result;
83
87
  const hint = hintFor(toolName, parsed);
84
88
  if (!hint || first.text.includes(hint)) return result;
85
89
  if (parsed) {
@@ -72,6 +72,27 @@ export function resultTextView(resultObject, textPaths = []) {
72
72
  return { view: 'json', view_path: null, text: JSON.stringify(resultObject, null, 2) };
73
73
  }
74
74
 
75
+ /**
76
+ * The non-text fields of `content` small enough to stay inline: everything
77
+ * except the text views (markdown, text, html, …) up to a quarter of the
78
+ * inline budget each. Null when there is nothing to keep.
79
+ */
80
+ export function keepSmallContentFields(content, textPaths = [], maxInline = DEFAULT_MAX_INLINE_CHARS) {
81
+ if (!content || typeof content !== 'object' || Array.isArray(content)) return null;
82
+ const textLeaves = new Set(
83
+ textPaths.filter((p) => p.startsWith('content.')).map((p) => p.slice('content.'.length))
84
+ );
85
+ const cap = Math.max(1000, Math.floor(maxInline / 4));
86
+ const kept = {};
87
+ for (const [key, value] of Object.entries(content)) {
88
+ if (textLeaves.has(key) || value === undefined) continue;
89
+ if (typeof value === 'string' && value.length > cap) continue;
90
+ if (typeof value === 'object' && value !== null && JSON.stringify(value).length > cap) continue;
91
+ kept[key] = value;
92
+ }
93
+ return Object.keys(kept).length > 0 ? kept : null;
94
+ }
95
+
75
96
  function warningsOf(resultObject) {
76
97
  return Array.isArray(resultObject.warnings) ? resultObject.warnings.filter((w) => typeof w === 'string') : [];
77
98
  }
@@ -129,7 +150,9 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
129
150
  }
130
151
 
131
152
  const preview = text.slice(0, maxInline);
132
- const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the first ${preview.length} chars of ${viewDesc} (${text.length} chars in total) and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.`;
153
+ const keptFields = keepSmallContentFields(resultObject.content, config.textPaths, maxInline);
154
+ const keptDesc = keptFields ? `; content.${Object.keys(keptFields).join(', content.')} kept inline` : '';
155
+ const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the first ${preview.length} chars of ${viewDesc} (${text.length} chars in total)${keptDesc} and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.`;
133
156
 
134
157
  const shaped = {};
135
158
  for (const [key, value] of Object.entries(resultObject)) {
@@ -143,6 +166,13 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
143
166
  if (resultObject.redaction && typeof resultObject.redaction === 'object') {
144
167
  shaped.redaction = resultObject.redaction;
145
168
  }
169
+ // The query-scoped formats live beside the page text under `content`
170
+ // (highlights, answer, json, metadata, links). They are the small, exact
171
+ // answer the caller paid for, and truncating the markdown must not drop
172
+ // them: an nhs.uk scrape with highlights and a question came back as a
173
+ // markdown preview and nothing else (R21, 2026-09-09). Keep every
174
+ // non-text `content` field that fits a quarter of the inline budget.
175
+ if (keptFields) shaped.content = keptFields;
146
176
  Object.assign(shaped, {
147
177
  preview,
148
178
  result_handle: handle,
@@ -6,14 +6,18 @@
6
6
  * plumbing. AsyncLocalStorage bridges that gap: the transport runs each
7
7
  * request inside a context, and withAuth reads it at invocation time.
8
8
  *
9
- * Today the only flag is `internal`: a request authenticated with the
9
+ * The first flag is `internal`: a request authenticated with the
10
10
  * INTERNAL_PROXY_SECRET (the crawlforge-website REST proxy). Internal requests
11
11
  * run tools normally but are billing-exempt — the website has already checked
12
12
  * and charged the end user's credits, so metering here would double-bill.
13
13
  *
14
- * The flag lives on the request context, never on the MCP session: a session
15
- * id created by an internal request grants nothing to a later request that
16
- * authenticates by other means.
14
+ * The second is `ownerToken`, which says WHICH of the website's customers an
15
+ * internal request is being made for (see internalOwnerToken below).
16
+ *
17
+ * Both live on the request context, never on the MCP session: a session id
18
+ * created by an internal request grants nothing to a later request that
19
+ * authenticates by other means, and an owner established on one request is not
20
+ * inherited by the next one down the same MCP session.
17
21
  */
18
22
 
19
23
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -25,6 +29,23 @@ export function isInternalRequest() {
25
29
  return requestContext.getStore()?.internal === true;
26
30
  }
27
31
 
32
+ /**
33
+ * The end user this internal-proxy request is being made for, or null.
34
+ *
35
+ * An opaque per-user token the website derives with an HMAC keyed on the shared
36
+ * internal secret (mcpOwnerToken in crawlforge-website
37
+ * src/lib/tools/mcp-proxy.ts), carried on the X-CrawlForge-Owner header. It is
38
+ * not reversible to a user id here and is not meant to be: all a stateful tool
39
+ * needs is a value that is stable for one customer and distinct between them.
40
+ *
41
+ * Only ever set on a request that already proved the internal secret, and only
42
+ * after the transport has validated its shape — authenticateRequest in
43
+ * transports/streamableHttp.js is the single place that decides both.
44
+ */
45
+ export function internalOwnerToken() {
46
+ return requestContext.getStore()?.ownerToken ?? null;
47
+ }
48
+
28
49
  /**
29
50
  * Record that the compliance gate refused this invocation before anything was
30
51
  * fetched — robots.txt disallowed the path, or the host is on the permanent
@@ -36,7 +57,7 @@ export function isInternalRequest() {
36
57
  * reaches withAuth. It also survives both routes a refusal can take — thrown,
37
58
  * or swallowed into an isError result.
38
59
  *
39
- * @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED'
60
+ * @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED' | 'USE_REDDIT_SEARCH'
40
61
  */
41
62
  export function markPreflightRefusal(code) {
42
63
  const store = requestContext.getStore();
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * toolFilter — client-side tool selection (Phase 6).
3
3
  *
4
- * Lets an MCP client load a subset of the 30 registered tools via env vars,
4
+ * Lets an MCP client load a subset of the 31 registered tools via env vars,
5
5
  * cutting context bloat (mirrors Bright Data / Exa's TOOLS / GROUPS pattern).
6
6
  *
7
7
  * Pure module: no I/O, no logging; process.env is only read via
@@ -17,7 +17,7 @@ export const TOOL_GROUPS = {
17
17
  search: ['search_web', 'serp_rank', 'reddit_search'],
18
18
  crawl: ['crawl_deep', 'map_site'],
19
19
  extract: ['extract_content', 'process_document', 'summarize_content', 'analyze_content', 'extract_structured', 'extract_with_llm', 'list_ollama_models', 'extract_embedded_state'],
20
- batch: ['batch_scrape', 'get_batch_results', 'scrape_with_actions'],
20
+ batch: ['batch_scrape', 'get_batch_results', 'scrape_with_actions', 'browser_session'],
21
21
  research: ['deep_research'],
22
22
  tracking: ['track_changes'],
23
23
  llmstxt: ['generate_llms_txt'],
@@ -314,9 +314,11 @@ export async function connectStreamableHttp(server, authManager, logger, options
314
314
  // — never expose an unauthenticated MCP endpoint on a public interface.
315
315
  // `internal` marks a request from the website's REST proxy
316
316
  // (INTERNAL_PROXY_SECRET): it is billing-exempt in withAuth because the
317
- // website already charged the end user. Request-scoped only never
318
- // persisted on the session.
317
+ // website already charged the end user. `ownerToken` says which of the
318
+ // website's customers it is being made for. Both are request-scoped only
319
+ // — never persisted on the session.
319
320
  let internal = false;
321
+ let ownerToken;
320
322
  if (!(authManager.isCreatorMode() && hostIsLoopback)) {
321
323
  const authResult = await authenticateRequest(req, authManager, oauthProvider);
322
324
  if (!authResult.ok) {
@@ -332,6 +334,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
332
334
  return;
333
335
  }
334
336
  internal = authResult.internal === true;
337
+ ownerToken = authResult.ownerToken;
335
338
  }
336
339
 
337
340
  // Era routing. Only a POST can carry the 2026-07-28 per-request envelope;
@@ -355,7 +358,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
355
358
  if (parsedBody !== undefined) {
356
359
  const probe = await toWebRequest(req, parsedBody);
357
360
  if (!(await isLegacyRequest(probe, parsedBody))) {
358
- await requestContext.run({ internal }, () => serveModern(req, res, parsedBody));
361
+ await requestContext.run({ internal, ownerToken }, () => serveModern(req, res, parsedBody));
359
362
  return;
360
363
  }
361
364
  }
@@ -370,7 +373,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
370
373
 
371
374
  if (existing) {
372
375
  await requestContext.run(
373
- { internal, servingServer: existing.server, servingEra: 'legacy' },
376
+ { internal, ownerToken, servingServer: existing.server, servingEra: 'legacy' },
374
377
  () => existing.transport.handleRequest(req, res, parsedBody)
375
378
  );
376
379
  return;
@@ -406,7 +409,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
406
409
  try {
407
410
  await sessionServer.connect(transport);
408
411
  await requestContext.run(
409
- { internal, servingServer: sessionServer, servingEra: 'legacy' },
412
+ { internal, ownerToken, servingServer: sessionServer, servingEra: 'legacy' },
410
413
  () => transport.handleRequest(req, res, parsedBody)
411
414
  );
412
415
  } catch (err) {
@@ -453,6 +456,28 @@ export async function connectStreamableHttp(server, authManager, logger, options
453
456
  };
454
457
  }
455
458
 
459
+ /**
460
+ * Hex, and bounded. The website emits 32 characters (mcpOwnerToken in
461
+ * crawlforge-website src/lib/tools/mcp-proxy.ts); the range is wider so the two
462
+ * repos can pick a different HMAC slice without a lockstep deploy, and narrow
463
+ * enough that nothing unbounded, non-printable or structured can ever become
464
+ * part of an owner id.
465
+ */
466
+ const OWNER_TOKEN_RE = /^[0-9a-f]{16,64}$/;
467
+
468
+ /**
469
+ * The owner token an internal request claims, or undefined.
470
+ *
471
+ * Undefined covers absent AND malformed alike, and the difference must not
472
+ * matter: everything downstream treats "no owner" as "no session", so a value
473
+ * that fails this check is simply not an owner rather than a strange one. Never
474
+ * relax this into a coercion — the token becomes part of a tenant key.
475
+ */
476
+ function readOwnerToken(req) {
477
+ const value = (req.headers['x-crawlforge-owner'] || '').toString();
478
+ return OWNER_TOKEN_RE.test(value) ? value : undefined;
479
+ }
480
+
456
481
  /**
457
482
  * Validate a request's credentials.
458
483
  *
@@ -460,13 +485,14 @@ export async function connectStreamableHttp(server, authManager, logger, options
460
485
  * - `X-Internal-Secret: <INTERNAL_PROXY_SECRET>` — server-to-server requests
461
486
  * from the crawlforge-website REST proxy. Returns { ok, internal: true };
462
487
  * internal requests are billing-exempt in withAuth (the website already
463
- * charged the end user). Only active when the env var is set.
488
+ * charged the end user). Only active when the env var is set. Such a
489
+ * request may also carry `X-CrawlForge-Owner` — see readOwnerToken.
464
490
  * - `Authorization: Bearer <crawlforge-api-key>` (legacy static key)
465
491
  * - `X-API-Key: <crawlforge-api-key>` (legacy static key)
466
492
  * - `Authorization: Bearer <oauth-access-token>` if OAuth is enabled —
467
493
  * the OAuth provider validates the token and maps it to the API key.
468
494
  *
469
- * @returns {Promise<{ok: true, internal?: boolean} | {ok: false, status: number, error: string, message: string, reason: string}>}
495
+ * @returns {Promise<{ok: true, internal?: boolean, ownerToken?: string} | {ok: false, status: number, error: string, message: string, reason: string}>}
470
496
  */
471
497
  async function authenticateRequest(req, authManager, oauthProvider) {
472
498
  // Internal proxy path first: presenting the header at all means the caller
@@ -480,7 +506,11 @@ async function authenticateRequest(req, authManager, oauthProvider) {
480
506
  const provided = createHash('sha256').update(providedSecret).digest();
481
507
  const expected = createHash('sha256').update(internalSecret).digest();
482
508
  if (timingSafeEqual(provided, expected)) {
483
- return { ok: true, internal: true };
509
+ // Read ONLY here, on the branch that has just proved the secret. A
510
+ // request that authenticated any other way — or none — never has its
511
+ // owner header looked at, so claiming an owner requires already being
512
+ // the proxy.
513
+ return { ok: true, internal: true, ownerToken: readOwnerToken(req) };
484
514
  }
485
515
  }
486
516
  return {
@@ -97,13 +97,20 @@ browser actions before extraction.
97
97
  }
98
98
  ```
99
99
 
100
- Allowed action types: `wait`, `click`, `type`, `press`, `scroll`, `screenshot`,
101
- `executeJavaScript`. `executeJavaScript` is disabled unless the deploy sets
100
+ Allowed action types: `snapshot`, `wait`, `click`, `type`, `press`, `scroll`,
101
+ `screenshot`, `executeJavaScript`, `select`, `hover`, `navigate`. Start a chain
102
+ with `{"type": "snapshot"}` to list the page's interactive elements with stable
103
+ refs (`@e1`, `@e2` …) and target those in later actions instead of guessing CSS
104
+ selectors. `executeJavaScript` is disabled unless the deploy sets
102
105
  `ALLOW_JAVASCRIPT_EXECUTION=true`. 1–20 actions per call. Screenshots are stored
103
106
  as `crawlforge://screenshot/{actionId}` resources. Full action schemas:
104
107
  [actions](references/actions.md). CLI:
105
108
  `crawlforge actions https://example.com --script login.json --screenshot`.
106
109
 
110
+ One-shot: the browser closes when the call returns. When the flow spans more
111
+ than one call, or you need to see the page before choosing what to click, use
112
+ `browser_session` instead (crawlforge-browser-sessions).
113
+
107
114
  ## generate_llms_txt — AI policy file (cost: 5)
108
115
 
109
116
  ```json
@@ -1,10 +1,16 @@
1
1
  # scrape_with_actions — Action Types
2
2
 
3
3
  `scrape_with_actions` runs an ordered `actions[]` array (1–20 items) before
4
- scraping. Only these 7 action types are allowed (allow-listed in ActionExecutor).
5
- Each action object has a `type` plus type-specific fields. Common optional
6
- fields on every action: `timeout`, `description`, `continueOnError`, `retries`
7
- (0–5), `captureAfter`.
4
+ scraping. Only these action types are allowed (allow-listed in ActionExecutor):
5
+ `snapshot`, `wait`, `click`, `type`, `press`, `scroll`, `screenshot`,
6
+ `executeJavaScript`, `select`, `hover`, `navigate`. Each action object has a
7
+ `type` plus type-specific fields. Common optional fields on every action:
8
+ `timeout`, `description`, `continueOnError`, `retries` (0–5), `captureAfter`.
9
+
10
+ **Start with a snapshot.** Section 8 returns the page's interactive elements
11
+ with stable refs, and any action's `selector` may name one (`@e1`) instead of a
12
+ CSS selector you have not seen. That is the difference between landing a
13
+ multi-step flow first try and burning the call on a guess.
8
14
 
9
15
  ## 1. wait
10
16
 
@@ -101,6 +107,46 @@ Disabled unless the deployment sets `ALLOW_JAVASCRIPT_EXECUTION=true`.
101
107
  { "type": "executeJavaScript", "script": "return document.title", "returnResult": true }
102
108
  ```
103
109
 
110
+ ## 8. snapshot
111
+
112
+ List the page's interactive elements, each with a stable ref later actions can
113
+ target. Refs are assigned `@e1…@eN` in document order and are **invalidated by
114
+ navigation** — snapshot again after one, or acting on an old ref fails with a
115
+ named error telling you to.
116
+
117
+ | Field | Type | Notes |
118
+ |-------|------|-------|
119
+ | `interactiveOnly` | boolean | Default true. False also lists headings and landmarks, which carry no ref. |
120
+ | `maxNodes` | number | Cap on nodes listed (default 200, max 1000). The result sets `truncated` when the cap stopped the walk. |
121
+
122
+ ```json
123
+ { "type": "snapshot" }
124
+ ```
125
+
126
+ The tree comes back in `actionResults[i].result.tree`, alongside `snapshotId`,
127
+ `url`, `title`, `refCount`, `nodeCount`, `truncated` and `interactiveOnly`:
128
+
129
+ ```
130
+ [document] "Sign in"
131
+ @e1 [textbox] "Email"
132
+ @e2 [textbox] "Password"
133
+ @e3 [button] "Sign in"
134
+ ```
135
+
136
+ ```json
137
+ [
138
+ { "type": "snapshot" },
139
+ { "type": "type", "selector": "@e1", "text": "user@example.com" },
140
+ { "type": "click", "selector": "@e3" }
141
+ ]
142
+ ```
143
+
144
+ The tool is stateless, so a chain cannot adapt to its own snapshot mid-flight:
145
+ read the refs in one call, act on them in the next. A fresh load of the same
146
+ page numbers them the same way, and the second chain snapshots again first so
147
+ the refs are stamped on the document it is acting against. The walk covers the
148
+ main frame only — elements inside iframes and shadow DOM get no refs.
149
+
104
150
  ## Top-level options
105
151
 
106
152
  | Option | Default | Notes |