From cd6b2adf0eeaaaa05a91ddcc9de08f65993fee64 Mon Sep 17 00:00:00 2001 From: Eric Ciarla Date: Tue, 15 Sep 2026 11:14:04 -0400 Subject: [PATCH 1/2] feat(cli): preserve and display API agent hints --- README.md | 16 ++ src/__tests__/alexandria-beta.test.ts | 35 +++ src/__tests__/commands/agent-hints.test.ts | 293 +++++++++++++++++++++ src/commands/alexandria.ts | 31 ++- src/commands/map.ts | 40 +-- src/commands/parse.ts | 31 ++- src/commands/scrape.ts | 40 ++- src/commands/search.ts | 66 ++--- src/index.ts | 7 + src/types/map.ts | 7 +- src/types/parse.ts | 5 +- src/types/scrape.ts | 6 +- src/types/search.ts | 5 +- src/utils/agent-hints.ts | 50 ++++ src/utils/client.ts | 10 +- src/utils/options.ts | 1 + src/utils/output.ts | 42 ++- 17 files changed, 596 insertions(+), 89 deletions(-) create mode 100644 src/__tests__/commands/agent-hints.test.ts create mode 100644 src/utils/agent-hints.ts diff --git a/README.md b/README.md index 20170e2d68..2d164f8802 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,22 @@ Command-line interface for Firecrawl. Search, scrape, interact, crawl, map, search research papers and developer sources, and run agent jobs directly from your terminal. +### API response guidance + +Search, Scrape, Parse, Map, and Alexandria commands preserve optional `agent_hints` returned by the API. These are up to three server-authored suggestions for useful next requests or feedback after evaluating a result. The CLI does not generate guidance, execute suggested requests, or submit feedback automatically. + +- JSON output includes `agent_hints` when returned, including empty results and errors. Scrape and Parse retain their existing document-shaped JSON, with hints alongside document fields. +- Readable results and raw page output show hints on stderr, so pipes and saved markdown/HTML stay clean. Capture stderr as well as stdout when an agent uses readable output. +- Pass `--no-agent-hints` to omit hints from CLI output, including JSON. This is local suppression; it does not change server-side feedback preferences or API behavior. + +```bash +firecrawl search "example research" --json +firecrawl scrape https://example.com -o page.md +firecrawl scrape https://example.com --json --no-agent-hints +``` + +SDK release prerequisite: authenticated Scrape, Map, and Alexandria calls require a Firecrawl SDK version that preserves outer response `agent_hints` on document/map/Alexandria results and `SdkError`. The currently pinned SDK `4.40.0` does not preserve all of these fields; upgrade it when that SDK change is released. Search and Parse preserve the HTTP response directly, and keyless Scrape preserves its response envelope. + ## Installation ```bash diff --git a/src/__tests__/alexandria-beta.test.ts b/src/__tests__/alexandria-beta.test.ts index caa21c480c..a8405ac46f 100644 --- a/src/__tests__/alexandria-beta.test.ts +++ b/src/__tests__/alexandria-beta.test.ts @@ -120,6 +120,41 @@ it('preserves mixed search results, tools and billing metadata', async () => { expect(readable.stdout).toContain('series/observations'); }); +it('preserves search hints on empty JSON responses and supports local suppression', async () => { + response = { + success: true, + id: 'search-empty', + data: { web: [] }, + agent_hints: ['After evaluation, submit feedback for search-empty.'], + }; + const args = ['search', 'example', '--sources', 'web', '--json']; + const result = await cli(args); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(response); + const suppressed = await cli([...args, '--no-agent-hints']); + expect(suppressed.code).toBe(0); + expect(JSON.parse(suppressed.stdout)).toEqual({ + success: true, + id: 'search-empty', + data: { web: [] }, + }); + expect(requests[0].body).toEqual(requests[1].body); +}); + +it('keeps server-authored search error guidance in JSON', async () => { + status = 400; + response = { + success: false, + error: 'Invalid source', + code: 'INVALID_BODY', + id: 'failed-search', + agent_hints: ['Use a supported search source.'], + }; + const result = await cli(['search', 'example', '--sources', 'web', '--json']); + expect(result.code).toBe(1); + expect(JSON.parse(result.stdout)).toEqual(response); +}); + it('sends provider calls to Scrape with a stable retry ID and preserves the receipt', async () => { const args = [ 'scrape', diff --git a/src/__tests__/commands/agent-hints.test.ts b/src/__tests__/commands/agent-hints.test.ts new file mode 100644 index 0000000000..5a1f6dba1a --- /dev/null +++ b/src/__tests__/commands/agent-hints.test.ts @@ -0,0 +1,293 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleSearchCommand } from '../../commands/search'; +import { handleScrapeCommand } from '../../commands/scrape'; +import { handleMapCommand } from '../../commands/map'; +import { handleParseCommand } from '../../commands/parse'; +import { handleAlexandria } from '../../commands/alexandria'; +import { getClient, isKeylessMode, keylessRequest } from '../../utils/client'; +import { initializeConfig, resetConfig } from '../../utils/config'; +import { agentHintMetadata } from '../../utils/agent-hints'; + +vi.mock('../../utils/client', async () => ({ + ...(await vi.importActual('../../utils/client')), + getClient: vi.fn(), + isKeylessMode: vi.fn(() => false), + keylessRequest: vi.fn(), +})); + +describe('server agent hints', () => { + const hints = [ + 'Inspect the returned tool definition.', + 'Submit feedback after evaluation.', + ]; + let directory: string; + let post: ReturnType; + let scrape: ReturnType; + let map: ReturnType; + let stdout: string[]; + let stderr: string[]; + let priorExitCode: typeof process.exitCode; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'firecrawl-hints-')); + stdout = []; + stderr = []; + priorExitCode = process.exitCode; + post = vi.fn(); + scrape = vi.fn(); + map = vi.fn(); + vi.mocked(getClient).mockReturnValue({ + http: { post }, + scrape, + map, + } as any); + vi.mocked(isKeylessMode).mockReturnValue(false); + initializeConfig({ + apiKey: 'fc-test', + apiUrl: 'https://api.firecrawl.dev', + }); + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { + stdout.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: any) => { + stderr.push(String(chunk)); + return true; + }); + vi.spyOn(console, 'error').mockImplementation((...args: any[]) => { + stderr.push(args.join(' ')); + }); + }); + + afterEach(() => { + process.exitCode = priorExitCode; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + resetConfig(); + rmSync(directory, { recursive: true, force: true }); + }); + + it('preserves empty search results, feedback identity and hints in JSON', async () => { + const envelope = { + success: true, + data: { web: [] }, + id: 'search-1', + creditsUsed: 0, + agent_hints: hints, + }; + post.mockResolvedValue({ data: envelope }); + await handleSearchCommand({ query: 'example', json: true }); + expect(JSON.parse(stdout.join(''))).toEqual(envelope); + expect(stderr).toEqual([]); + }); + + it('keeps search hints out of readable results and output files', async () => { + post.mockResolvedValue({ + data: { + success: true, + data: { web: [{ url: 'https://example.com', title: 'Example' }] }, + agent_hints: hints, + }, + }); + const output = join(directory, 'search.txt'); + await handleSearchCommand({ query: 'example', output }); + expect(readFileSync(output, 'utf8')).toContain('Example'); + expect(readFileSync(output, 'utf8')).not.toContain(hints[0]); + expect(stderr.join('')).toContain(hints[0]); + expect(stdout).toEqual([]); + }); + + it('returns JSON on typed search failure with its hints and identity', async () => { + const failure = { + success: false, + error: 'Invalid source', + code: 'INVALID_BODY', + id: 'search-2', + agent_hints: hints, + }; + post.mockRejectedValue( + Object.assign(new Error('Invalid source'), { + response: { data: failure }, + }) + ); + await handleSearchCommand({ query: 'example', json: true }); + expect(JSON.parse(stdout.join(''))).toEqual(failure); + expect(process.exitCode).toBe(1); + }); + + it('can suppress hints in JSON without adding a server request option', async () => { + post.mockResolvedValue({ + data: { success: true, data: { web: [] }, agent_hints: hints }, + }); + await handleSearchCommand({ + query: 'example', + json: true, + agentHints: false, + }); + expect(JSON.parse(stdout.join(''))).toEqual({ + success: true, + data: { web: [] }, + }); + expect(post.mock.calls[0][1]).not.toHaveProperty('agentHints'); + expect(stderr).toEqual([]); + }); + + it('leaves ordinary scrape content unchanged when no hints are returned', async () => { + scrape.mockResolvedValue({ markdown: '# Page' }); + await handleScrapeCommand({ url: 'https://example.com' }); + expect(stdout.join('')).toBe('# Page\n'); + expect(stderr).toEqual([]); + }); + + it('retains SDK hints in flattened scrape JSON and multiple-format JSON', async () => { + scrape.mockResolvedValue({ + markdown: '# Page', + links: ['https://example.com'], + agent_hints: hints, + }); + await handleScrapeCommand({ + url: 'https://example.com', + formats: ['markdown', 'links'], + }); + expect(JSON.parse(stdout.join(''))).toEqual({ + markdown: '# Page', + links: ['https://example.com'], + agent_hints: hints, + }); + expect(stderr).toEqual([]); + }); + + it('preserves keyless envelope hints while writing only page text to a raw file', async () => { + vi.mocked(isKeylessMode).mockReturnValue(true); + vi.mocked(keylessRequest).mockResolvedValue({ + success: true, + data: { markdown: '# Page' }, + agent_hints: hints, + }); + const output = join(directory, 'page.md'); + await handleScrapeCommand({ url: 'https://example.com', output }); + expect(readFileSync(output, 'utf8')).toBe('# Page'); + expect(stderr.join('')).toContain(hints[0]); + }); + + it('honors hint suppression for SDK scrape results and query output', async () => { + scrape.mockResolvedValue({ answer: '42', agent_hints: hints }); + await handleScrapeCommand({ + url: 'https://example.com', + query: 'How many?', + agentHints: false, + }); + expect(stdout.join('')).toBe('42\n'); + expect(stderr).toEqual([]); + }); + + it('preserves a keyless failure envelope even when HTTP succeeded', async () => { + vi.mocked(isKeylessMode).mockReturnValue(true); + const failure = { + success: false, + error: 'Scrape failed', + code: 'SCRAPE_FAILED', + agent_hints: hints, + }; + vi.mocked(keylessRequest).mockResolvedValue(failure); + await handleScrapeCommand({ url: 'https://example.com', json: true }); + expect(JSON.parse(stdout.join(''))).toEqual(failure); + expect(process.exitCode).toBe(1); + }); + + it('keeps query-mode metadata when JSON was explicitly requested', async () => { + scrape.mockResolvedValue({ answer: '42', agent_hints: hints }); + await handleScrapeCommand({ + url: 'https://example.com', + query: 'How many?', + json: true, + }); + expect(JSON.parse(stdout.join(''))).toEqual({ + answer: '42', + agent_hints: hints, + }); + }); + + it('retains error hints exposed by the SDK and returns a failing exit code', async () => { + scrape.mockRejectedValue( + Object.assign(new Error('Request failed'), { agent_hints: hints }) + ); + await handleScrapeCommand({ url: 'https://example.com', json: true }); + expect(JSON.parse(stdout.join(''))).toEqual({ + success: false, + error: 'Request failed', + agent_hints: hints, + }); + expect(process.exitCode).toBe(1); + }); + + it('preserves map hints with an empty link list', async () => { + map.mockResolvedValue({ id: 'map-1', links: [], agent_hints: hints }); + await handleMapCommand({ urlOrJobId: 'https://example.com', json: true }); + expect(JSON.parse(stdout.join(''))).toEqual({ + success: true, + id: 'map-1', + data: { links: [] }, + agent_hints: hints, + }); + }); + + it.each([true, false])( + 'preserves parse hints on success=%s', + async (success) => { + const file = join(directory, 'page.html'); + writeFileSync(file, '

Page

'); + const envelope = success + ? { success, data: { markdown: '# Page' }, agent_hints: hints } + : { success, error: 'Parse failed', agent_hints: hints }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: success, + status: success ? 200 : 400, + json: async () => envelope, + }) + ); + await handleParseCommand({ file, json: true }); + expect(JSON.parse(stdout.join(''))).toEqual( + success ? { markdown: '# Page', agent_hints: hints } : envelope + ); + } + ); + + it('preserves Alexandria receipt, partial results and SDK hints', async () => { + const alexandria = [ + { provider: 'example', capability: 'lookup', error: { code: 'FAILED' } }, + ]; + scrape.mockResolvedValue({ + alexandria, + creditsCost: 1, + scrapeId: 'scrape-3', + agent_hints: hints, + }); + await handleAlexandria( + [{ provider: 'example', capability: 'lookup', options: {} }], + { requestId: 'retry-1' } + ); + expect(JSON.parse(stdout.join(''))).toEqual({ + success: true, + scrape_id: 'scrape-3', + data: { alexandria, creditsCost: 1 }, + requestId: 'retry-1', + agent_hints: hints, + }); + expect(process.exitCode).toBe(1); + }); + + it('accepts only string hints and caps malformed upstream arrays at three', () => { + expect( + agentHintMetadata({ agent_hints: ['one', null, 'two', 'three', 'four'] }) + ).toEqual({ agent_hints: ['one', 'two', 'three'] }); + expect(agentHintMetadata({ agent_hints: 'not an array' })).toEqual({}); + expect(agentHintMetadata({})).toEqual({}); + }); +}); diff --git a/src/commands/alexandria.ts b/src/commands/alexandria.ts index 7cb350027c..f1586533ba 100644 --- a/src/commands/alexandria.ts +++ b/src/commands/alexandria.ts @@ -4,9 +4,15 @@ import { SdkError, type AlexandriaCall } from 'firecrawl'; import { getClient } from '../utils/client'; import { getApiKey } from '../utils/config'; import { writeOutput } from '../utils/output'; +import { + agentHintMetadata, + errorAgentHints, + type AgentHintMetadata, + type AgentHintOptions, +} from '../utils/agent-hints'; type Call = AlexandriaCall & { options: Record }; -type Options = { +type Options = AgentHintOptions & { apiKey?: string; apiUrl?: string; requestId?: string; @@ -51,7 +57,20 @@ export function buildCalls(addresses: string[], values: string[] = []): Call[] { }); } -export function apiFailure(error: unknown): Record { +interface ApiFailure extends AgentHintMetadata { + success: false; + error: string; + code?: string; + chargeId?: string; + requiresAction?: unknown; + id?: string; + scrape_id?: string; +} + +export function apiFailure( + error: unknown, + fallback = 'Request failed' +): ApiFailure { const body = (error as any)?.response?.data ?? (error instanceof SdkError @@ -70,10 +89,13 @@ export function apiFailure(error: unknown): Record { ? body.error : error instanceof Error ? error.message - : 'Request failed', + : fallback, ...(typeof body?.code === 'string' && { code: body.code }), ...(typeof body?.chargeId === 'string' && { chargeId: body.chargeId }), ...(body?.requiresAction && { requiresAction: body.requiresAction }), + ...(typeof body?.id === 'string' && { id: body.id }), + ...(typeof body?.scrape_id === 'string' && { scrape_id: body.scrape_id }), + ...errorAgentHints(error), }; } @@ -98,6 +120,7 @@ export async function handleAlexandria( }); envelope = { success: true, + ...agentHintMetadata(result), ...(result.scrapeId && { scrape_id: result.scrapeId }), data: { alexandria: result.alexandria, @@ -111,6 +134,7 @@ export async function handleAlexandria( !envelope.success || envelope.data?.alexandria?.some((item: any) => item.error); if (failed) process.exitCode = 1; + if (options.agentHints === false) delete envelope.agent_hints; writeOutput( JSON.stringify( { ...envelope, requestId }, @@ -136,6 +160,7 @@ export function createFindToolsCommand(): Command { .option('-o, --output ', 'Output file') .option('--json', 'Output JSON') .option('--pretty', 'Format JSON') + .option('--no-agent-hints', 'Omit server guidance from CLI output') .action(async (urls: string[], options) => { let call: Call = { provider: 'firecrawl', diff --git a/src/commands/map.ts b/src/commands/map.ts index bbac27be84..63da23d85c 100644 --- a/src/commands/map.ts +++ b/src/commands/map.ts @@ -5,6 +5,12 @@ import type { MapOptions, MapResult } from '../types/map'; import { getClient } from '../utils/client'; import { writeOutput } from '../utils/output'; +import { + agentHintMetadata, + withoutAgentHints, + writeAgentHints, +} from '../utils/agent-hints'; +import { apiFailure } from './alexandria'; /** * Execute map command @@ -43,6 +49,8 @@ export async function executeMap(options: MapOptions): Promise { return { success: true, + ...(mapData.id && { id: mapData.id }), + ...agentHintMetadata(mapData), data: { links: mapData.links.map((link: any) => ({ url: link.url, @@ -52,10 +60,7 @@ export async function executeMap(options: MapOptions): Promise { }, }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }; + return apiFailure(error, 'Unknown error occurred'); } } @@ -73,7 +78,20 @@ function formatMapReadable(data: MapResult['data']): string { * Handle map command output */ export async function handleMapCommand(options: MapOptions): Promise { - const result = await executeMap(options); + const response = await executeMap(options); + const result = + options.agentHints === false ? withoutAgentHints(response) : response; + + if (options.json) { + writeOutput( + JSON.stringify(result, null, options.pretty ? 2 : undefined), + options.output, + !!options.output + ); + if (!result.success) process.exitCode = 1; + return; + } + writeAgentHints(result); if (!result.success) { console.error('Error:', result.error); @@ -84,17 +102,7 @@ export async function handleMapCommand(options: MapOptions): Promise { return; } - let outputContent: string; - - // Use JSON format if --json flag is set - if (options.json) { - outputContent = options.pretty - ? JSON.stringify({ success: true, data: result.data }, null, 2) - : JSON.stringify({ success: true, data: result.data }); - } else { - // Default to human-readable format (one URL per line) - outputContent = formatMapReadable(result.data); - } + const outputContent = formatMapReadable(result.data); writeOutput(outputContent, options.output, !!options.output); } diff --git a/src/commands/parse.ts b/src/commands/parse.ts index 4aa832fd9f..c77618ecff 100644 --- a/src/commands/parse.ts +++ b/src/commands/parse.ts @@ -13,7 +13,13 @@ import type { ParseOptions, ParseResult } from '../types/parse'; import type { ScrapeFormat } from '../types/scrape'; import { getClient, isKeylessMode } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; -import { handleScrapeOutput } from '../utils/output'; +import { handleScrapeOutput, shouldOutputJson } from '../utils/output'; +import { + agentHintMetadata, + withoutAgentHints, + writeAgentHints, +} from '../utils/agent-hints'; +import { apiFailure } from './alexandria'; const DEFAULT_API_URL = 'https://api.firecrawl.dev'; @@ -198,12 +204,17 @@ export async function executeParse( const message = payload?.error || `HTTP ${response.status}: ${response.statusText || 'Request failed'}`; - return { success: false, error: message }; + return { + ...apiFailure({ response: { data: payload } }), + success: false, + error: message, + }; } return { success: true, - data: payload?.data ?? payload, + data: withoutAgentHints(payload?.data ?? payload), + ...agentHintMetadata(payload), }; } catch (error) { const requestEndTime = Date.now(); @@ -220,10 +231,18 @@ export async function executeParse( * /v2/parse response shape matches /v2/scrape. */ export async function handleParseCommand(options: ParseOptions): Promise { - const result = await executeParse(options); - - if (options.query && result.success && result.data?.answer) { + const response = await executeParse(options); + const result = + options.agentHints === false ? withoutAgentHints(response) : response; + + if ( + options.query && + result.success && + result.data?.answer && + !shouldOutputJson(options.output, options.json) + ) { const { writeOutput } = await import('../utils/output'); + writeAgentHints(result); writeOutput(result.data.answer, options.output, !!options.output); return; } diff --git a/src/commands/scrape.ts b/src/commands/scrape.ts index 6b693935b2..f2b31aed33 100644 --- a/src/commands/scrape.ts +++ b/src/commands/scrape.ts @@ -10,7 +10,16 @@ import type { ScrapeLocation, } from '../types/scrape'; import { getClient, isKeylessMode, keylessRequest } from '../utils/client'; -import { handleScrapeOutput, writeOutput } from '../utils/output'; +import { + handleScrapeOutput, + writeOutput, + shouldOutputJson, +} from '../utils/output'; +import { + agentHintMetadata, + withoutAgentHints, + writeAgentHints, +} from '../utils/agent-hints'; import { saveInteractSession, clearInteractSession, @@ -18,7 +27,7 @@ import { import { getOrigin } from '../utils/url'; import { executeMap } from './map'; import { getStatus } from './status'; -import { requireAlexandriaKey } from './alexandria'; +import { requireAlexandriaKey, apiFailure } from './alexandria'; /** * Output timing information if requested @@ -148,6 +157,7 @@ export async function executeScrape( scrapeParams.domainTools = true; } let result: any; + let hints = {}; if (isKeylessMode(options.apiKey, options.apiUrl)) { // Keyless free tier: header-less request. The API identifies the CLI via // the `integration: 'cli'` field already in scrapeParams. @@ -155,13 +165,18 @@ export async function executeScrape( url: options.url, ...scrapeParams, }); + if (json?.success === false) { + return apiFailure({ response: { data: json } }); + } result = json?.data ?? json; + hints = agentHintMetadata(json); } else { const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl, }); result = await app.scrape(options.url, scrapeParams); + hints = agentHintMetadata(result); } const requestEndTime = Date.now(); outputTiming(options, requestStartTime, requestEndTime); @@ -185,16 +200,14 @@ export async function executeScrape( return { success: true, - data: result, + data: withoutAgentHints(result), + ...hints, }; } catch (error) { const requestEndTime = Date.now(); outputTiming(options, requestStartTime, requestEndTime, error); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }; + return apiFailure(error, 'Unknown error occurred'); } } @@ -204,10 +217,18 @@ export async function executeScrape( export async function handleScrapeCommand( options: ScrapeOptions ): Promise { - const result = await executeScrape(options); + const response = await executeScrape(options); + const result = + options.agentHints === false ? withoutAgentHints(response) : response; // Query mode: output answer directly - if (options.query && result.success && result.data?.answer) { + if ( + options.query && + result.success && + result.data?.answer && + !shouldOutputJson(options.output, options.json) + ) { + writeAgentHints(result); writeOutput(result.data.answer, options.output, !!options.output); return; } @@ -274,6 +295,7 @@ export async function handleMultiScrapeCommand( const promises = urls.map(async (url) => { const scrapeOptions: ScrapeOptions = { ...options, url }; const result = await executeScrape(scrapeOptions); + writeAgentHints(result, options.agentHints); const currentCount = ++completedCount; diff --git a/src/commands/search.ts b/src/commands/search.ts index 4181ed37a0..f8064fc220 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -15,6 +15,11 @@ import type { import { getClient, isKeylessMode, keylessRequest } from '../utils/client'; import { writeOutput } from '../utils/output'; import { apiFailure, requireAlexandriaKey } from './alexandria'; +import { + agentHintMetadata, + writeAgentHints, + withoutAgentHints, +} from '../utils/agent-hints'; /** * Execute search command @@ -123,6 +128,10 @@ export async function executeSearch( ); envelope = (httpResponse?.data ?? {}) as Record; } + if (envelope.success === false) + return apiFailure({ + response: { data: envelope }, + }); const payload = (envelope.data ?? {}) as Record; const data: SearchResultData = {}; @@ -141,17 +150,10 @@ export async function executeSearch( warning: envelope.warning, id: envelope.id, creditsUsed: envelope.creditsUsed, + ...agentHintMetadata(envelope), }; } catch (error) { - return { - success: false, - error: - options.domainTools || options.sources?.includes('alexandria') - ? JSON.stringify(apiFailure(error)) - : error instanceof Error - ? error.message - : 'Unknown error occurred', - }; + return apiFailure(error, 'Unknown error occurred'); } } @@ -293,7 +295,21 @@ function formatSearchReadable( export async function handleSearchCommand( options: SearchOptions ): Promise { - const result = await executeSearch(options); + const response = await executeSearch(options); + const result = + options.agentHints === false ? withoutAgentHints(response) : response; + const json = options.json || options.pretty; + + if (json) { + writeOutput( + JSON.stringify(result, null, options.pretty ? 2 : undefined), + options.output, + !!options.output + ); + if (!result.success) process.exitCode = 1; + return; + } + writeAgentHints(result); if (!result.success) { console.error('Error:', result.error); @@ -312,38 +328,12 @@ export async function handleSearchCommand( (result.data.news && result.data.news.length > 0) || (result.data.developer && result.data.developer.length > 0); - if (!hasResults && !(result.data.tools && (options.json || options.pretty))) { + if (!hasResults) { console.log('No results found.'); return; } - let outputContent: string; - - // Use JSON format if --json or --pretty flag is set - // --pretty implies JSON output - if (options.json || options.pretty) { - const jsonOutput: Record = { - success: true, - data: result.data, - }; - - if (result.warning) { - jsonOutput.warning = result.warning; - } - if (result.id) { - jsonOutput.id = result.id; - } - if (result.creditsUsed !== undefined) { - jsonOutput.creditsUsed = result.creditsUsed; - } - - outputContent = options.pretty - ? JSON.stringify(jsonOutput, null, 2) - : JSON.stringify(jsonOutput); - } else { - // Default to human-readable format - outputContent = formatSearchReadable(result.data, options); - } + const outputContent = formatSearchReadable(result.data, options); writeOutput(outputContent, options.output, !!options.output); } diff --git a/src/index.ts b/src/index.ts index 9132719df9..7c056168d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -382,6 +382,7 @@ function createScrapeCommand(): Command { ) .option('--api-url ', 'API URL (overrides global --api-url)') .option('-o, --output ', 'Output file path (default: stdout)') + .option('--no-agent-hints', 'Omit server guidance from CLI output') .option('--json', 'Output as JSON format', false) .option('--pretty', 'Pretty print JSON output', false) .option( @@ -801,6 +802,7 @@ function createMapCommand(): Command { ) .option('--api-url ', 'API URL (overrides global --api-url)') .option('-o, --output ', 'Output file path (default: stdout)') + .option('--no-agent-hints', 'Omit server guidance from CLI output') .option('--json', 'Output as JSON format', false) .option('--pretty', 'Pretty print JSON output', false) .action(async (positionalUrl, options) => { @@ -818,6 +820,7 @@ function createMapCommand(): Command { wait: options.wait, output: options.output, json: options.json, + agentHints: options.agentHints, pretty: options.pretty, apiKey: options.apiKey, apiUrl: options.apiUrl, @@ -868,6 +871,7 @@ function createParseCommand(): Command { ) .option('--api-url ', 'API URL (overrides global --api-url)') .option('-o, --output ', 'Output file path (default: stdout)') + .option('--no-agent-hints', 'Omit server guidance from CLI output') .option('--json', 'Output as JSON format', false) .option('--pretty', 'Pretty print JSON output', false) .option( @@ -918,6 +922,7 @@ Max upload size: 50 MB output: options.output, pretty: options.pretty, json: options.json, + agentHints: options.agentHints, timing: options.timing, query: options.query, }); @@ -992,6 +997,7 @@ function createSearchCommand(): Command { ) .option('--api-url ', 'API URL (overrides global --api-url)') .option('-o, --output ', 'Output file path (default: stdout)') + .option('--no-agent-hints', 'Omit server guidance from CLI output') // .option( // '-p, --pretty', // 'Output as pretty JSON (default: human-readable)', @@ -1064,6 +1070,7 @@ function createSearchCommand(): Command { apiUrl: options.apiUrl, output: options.output, json: options.json, + agentHints: options.agentHints, pretty: options.pretty, }; diff --git a/src/types/map.ts b/src/types/map.ts index 07bf3e8fc6..6d9aa6dce0 100644 --- a/src/types/map.ts +++ b/src/types/map.ts @@ -2,7 +2,9 @@ * Types for map command */ -export interface MapOptions { +import type { AgentHintMetadata, AgentHintOptions } from '../utils/agent-hints'; + +export interface MapOptions extends AgentHintOptions { /** API key for Firecrawl */ apiKey?: string; /** API URL for Firecrawl */ @@ -33,8 +35,9 @@ export interface MapOptions { timeout?: number; } -export interface MapResult { +export interface MapResult extends AgentHintMetadata { success: boolean; + id?: string; data?: { links: Array<{ url: string; diff --git a/src/types/parse.ts b/src/types/parse.ts index 7499ef541b..945f703da2 100644 --- a/src/types/parse.ts +++ b/src/types/parse.ts @@ -3,8 +3,9 @@ */ import type { ScrapeFormat, ScrapeLocation } from './scrape'; +import type { AgentHintMetadata, AgentHintOptions } from '../utils/agent-hints'; -export interface ParseOptions { +export interface ParseOptions extends AgentHintOptions { /** Local file path to parse */ file: string; /** Output format(s) */ @@ -35,7 +36,7 @@ export interface ParseOptions { query?: string; } -export interface ParseResult { +export interface ParseResult extends AgentHintMetadata { success: boolean; data?: any; error?: string; diff --git a/src/types/scrape.ts b/src/types/scrape.ts index ee323751f1..7f0918c4c5 100644 --- a/src/types/scrape.ts +++ b/src/types/scrape.ts @@ -2,6 +2,8 @@ * Types and interfaces for the scrape command */ +import type { AgentHintMetadata, AgentHintOptions } from '../utils/agent-hints'; + export type ScrapeFormat = | 'markdown' | 'html' @@ -22,7 +24,7 @@ export interface ScrapeLocation { languages?: string[]; } -export interface ScrapeOptions { +export interface ScrapeOptions extends AgentHintOptions { domainTools?: boolean; /** URL to scrape */ url: string; @@ -75,7 +77,7 @@ export interface ScrapeOptions { redactPII?: boolean; } -export interface ScrapeResult { +export interface ScrapeResult extends AgentHintMetadata { success: boolean; data?: any; error?: string; diff --git a/src/types/search.ts b/src/types/search.ts index 8378a60ec5..f35bcf2462 100644 --- a/src/types/search.ts +++ b/src/types/search.ts @@ -3,11 +3,12 @@ */ import type { ScrapeFormat } from './scrape'; +import type { AgentHintMetadata, AgentHintOptions } from '../utils/agent-hints'; export type SearchSource = 'web' | 'images' | 'news' | 'alexandria'; export type SearchCategory = 'github' | 'research' | 'pdf' | 'developer'; -export interface SearchOptions { +export interface SearchOptions extends AgentHintOptions { domainTools?: boolean; /** Search query (required) */ query: string; @@ -121,7 +122,7 @@ export interface SearchResultData { developer?: DeveloperSearchResult[]; } -export interface SearchResult { +export interface SearchResult extends AgentHintMetadata { success: boolean; data?: SearchResultData; warning?: string; diff --git a/src/utils/agent-hints.ts b/src/utils/agent-hints.ts new file mode 100644 index 0000000000..e1f98b5809 --- /dev/null +++ b/src/utils/agent-hints.ts @@ -0,0 +1,50 @@ +/** Server-authored guidance. The CLI displays it; it never executes it. */ +export interface AgentHintOptions { + /** Suppress returned guidance locally, including in JSON output. */ + agentHints?: boolean; +} + +export interface AgentHintMetadata { + agent_hints?: string[]; +} + +/** Only accept the documented response field; never derive hints from content. */ +export function agentHintMetadata( + source: unknown, + enabled = true +): AgentHintMetadata { + if (!enabled || !source || typeof source !== 'object') return {}; + const hints = (source as AgentHintMetadata).agent_hints; + if (!Array.isArray(hints)) return {}; + return { + agent_hints: hints.filter((hint) => typeof hint === 'string').slice(0, 3), + }; +} + +/** SDK convenience methods may place response hints beside document fields. */ +export function withoutAgentHints(value: T): T { + if (!value || typeof value !== 'object' || !('agent_hints' in value)) { + return value; + } + const { agent_hints: _hints, ...rest } = value; + return rest as T; +} + +export function errorAgentHints(error: unknown): AgentHintMetadata { + const value = error as any; + return { + ...agentHintMetadata(value?.details), + ...agentHintMetadata(value?.response?.data), + ...agentHintMetadata(error), + }; +} + +/** stderr keeps raw page content, pipes, and output files free of guidance. */ +export function writeAgentHints(source: unknown, enabled = true): void { + const hints = agentHintMetadata(source, enabled).agent_hints; + if (hints?.length) { + process.stderr.write( + `Agent hints:\n${hints.map((hint) => `- ${hint}`).join('\n')}\n` + ); + } +} diff --git a/src/utils/client.ts b/src/utils/client.ts index 6519495bfc..84301fe2d0 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -40,9 +40,13 @@ export async function keylessRequest( body: JSON.stringify(body), }); const json: any = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error( - json?.error || `Firecrawl request failed (HTTP ${response.status})` + if (!response.ok || json?.success === false) { + // Keep the API envelope so command renderers can preserve typed errors and hints. + throw Object.assign( + new Error( + json?.error || `Firecrawl request failed (HTTP ${response.status})` + ), + { response: { data: json } } ); } return json; diff --git a/src/utils/options.ts b/src/utils/options.ts index 9d53d61bb0..1f014e199b 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -117,6 +117,7 @@ export function parseScrapeOptions(options: any): ScrapeOptions { output: options.output, pretty: options.pretty, json: options.json, + agentHints: options.agentHints, timing: options.timing, maxAge: options.maxAge, location, diff --git a/src/utils/output.ts b/src/utils/output.ts index 273e6bc51c..c3b398edce 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -5,11 +5,15 @@ import * as fs from 'fs'; import * as path from 'path'; import type { ScrapeResult, ScrapeFormat } from '../types/scrape'; +import { agentHintMetadata, writeAgentHints } from './agent-hints'; /** * Determine if output should be JSON based on flag or file extension */ -function shouldOutputJson(outputPath?: string, jsonFlag?: boolean): boolean { +export function shouldOutputJson( + outputPath?: string, + jsonFlag?: boolean +): boolean { // Explicit --json flag takes precedence if (jsonFlag) return true; @@ -177,28 +181,51 @@ export function handleScrapeOutput( pretty: boolean = false, json: boolean = false ): void { + const explicitJson = shouldOutputJson(outputPath, json); if (!result.success) { + if (explicitJson) { + writeOutput( + JSON.stringify(result, null, pretty ? 2 : undefined), + outputPath, + !!outputPath + ); + process.exitCode = 1; + return; + } + writeAgentHints(result); // Always use stderr for errors to allow piping console.error('Error:', result.error); process.exit(1); + return; } if (!result.data) { + if (explicitJson) { + writeOutput( + JSON.stringify(result, null, pretty ? 2 : undefined), + outputPath, + !!outputPath + ); + } else { + writeAgentHints(result); + } return; } // Determine if we should force JSON output - const forceJson = - shouldOutputJson(outputPath, json) || - Array.isArray((result.data as any).tools); + const forceJson = explicitJson || Array.isArray((result.data as any).tools); // If JSON is forced, always output JSON regardless of format if (forceJson) { let jsonContent: string; try { jsonContent = pretty - ? JSON.stringify(result.data, null, 2) - : JSON.stringify(result.data); + ? JSON.stringify( + { ...result.data, ...agentHintMetadata(result) }, + null, + 2 + ) + : JSON.stringify({ ...result.data, ...agentHintMetadata(result) }); } catch (error) { jsonContent = JSON.stringify({ error: 'Failed to serialize response', @@ -219,6 +246,7 @@ export function handleScrapeOutput( if (isSingleFormat && isRawTextFormat && singleFormat) { const content = extractContent(result.data, singleFormat); if (content !== null) { + writeAgentHints(result); writeOutput(content, outputPath, !!outputPath); return; } @@ -231,6 +259,7 @@ export function handleScrapeOutput( result.data.screenshot ) { const content = formatScreenshotOutput(result.data); + writeAgentHints(result); writeOutput(content, outputPath, !!outputPath); return; } @@ -245,6 +274,7 @@ export function handleScrapeOutput( // Multiple formats - extract only requested formats outputData = extractMultipleFormats(result.data, formats); } + outputData = { ...outputData, ...agentHintMetadata(result) }; let jsonContent: string; try { From ccd300cc019beaefd5193785b25c6ad32c0ca8f4 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Thu, 17 Sep 2026 16:34:18 +1000 Subject: [PATCH 2/2] fix(cli): opt in to API agent hints --- README.md | 2 + src/__tests__/commands/parse.test.ts | 5 +- .../utils/client-agent-hints.test.ts | 66 +++++++++++++++++++ src/commands/parse.ts | 8 ++- src/utils/client.ts | 37 +++++++++-- 5 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/utils/client-agent-hints.test.ts diff --git a/README.md b/README.md index 2d164f8802..533cfd552f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Command-line interface for Firecrawl. Search, scrape, interact, crawl, map, sear Search, Scrape, Parse, Map, and Alexandria commands preserve optional `agent_hints` returned by the API. These are up to three server-authored suggestions for useful next requests or feedback after evaluating a result. The CLI does not generate guidance, execute suggested requests, or submit feedback automatically. +The CLI explicitly opts into this guidance with the `X-Firecrawl-Agent-Hints: true` request header. The API leaves hints disabled by default for ordinary HTTP and SDK callers. + - JSON output includes `agent_hints` when returned, including empty results and errors. Scrape and Parse retain their existing document-shaped JSON, with hints alongside document fields. - Readable results and raw page output show hints on stderr, so pipes and saved markdown/HTML stay clean. Capture stderr as well as stdout when an agent uses readable output. - Pass `--no-agent-hints` to omit hints from CLI output, including JSON. This is local suppression; it does not change server-side feedback preferences or API behavior. diff --git a/src/__tests__/commands/parse.test.ts b/src/__tests__/commands/parse.test.ts index 18b126d664..bce39ff94a 100644 --- a/src/__tests__/commands/parse.test.ts +++ b/src/__tests__/commands/parse.test.ts @@ -61,7 +61,9 @@ describe('executeParse', () => { ]; expect(url).toBe('https://api.firecrawl.dev/v2/parse'); expect(init.method).toBe('POST'); - expect(init.headers).toBeUndefined(); + expect(init.headers).toEqual({ + 'X-Firecrawl-Agent-Hints': 'true', + }); const options = JSON.parse(init.body.get('options') as string); expect(options).toEqual({ @@ -84,6 +86,7 @@ describe('executeParse', () => { { headers?: Record }, ]; expect(init.headers).toEqual({ + 'X-Firecrawl-Agent-Hints': 'true', Authorization: 'Bearer fc-test-key', }); }); diff --git a/src/__tests__/utils/client-agent-hints.test.ts b/src/__tests__/utils/client-agent-hints.test.ts new file mode 100644 index 0000000000..a5e1a817cd --- /dev/null +++ b/src/__tests__/utils/client-agent-hints.test.ts @@ -0,0 +1,66 @@ +import { createServer, type Server } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getClient, keylessRequest } from '../../utils/client'; +import { initializeConfig } from '../../utils/config'; +import { setupTest, teardownTest } from './mock-client'; + +vi.mock('../../utils/credentials', () => ({ + loadCredentials: vi.fn(() => null), +})); + +describe('agent hints request opt-in', () => { + let server: Server; + let apiUrl: string; + let requestHeaders: Array>; + + beforeEach(async () => { + setupTest(); + requestHeaders = []; + server = createServer((request, response) => { + requestHeaders.push(request.headers); + request.resume(); + request.on('end', () => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ success: true })); + }); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Test server did not bind to a TCP port'); + } + apiUrl = `http://127.0.0.1:${address.port}`; + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + teardownTest(); + }); + + it('opts in on authenticated SDK requests', async () => { + const client = getClient({ + apiKey: 'fc-test', + apiUrl, + maxRetries: 1, + }); + + await (client as any).http.post('/v2/search', { query: 'test' }); + + expect(requestHeaders).toHaveLength(1); + expect(requestHeaders[0]['x-firecrawl-agent-hints']).toBe('true'); + }); + + it('opts in on keyless requests', async () => { + initializeConfig({ apiUrl }); + + await keylessRequest('/v2/search', { query: 'test' }); + + expect(requestHeaders).toHaveLength(1); + expect(requestHeaders[0]['x-firecrawl-agent-hints']).toBe('true'); + }); +}); diff --git a/src/commands/parse.ts b/src/commands/parse.ts index c77618ecff..2e3485ee00 100644 --- a/src/commands/parse.ts +++ b/src/commands/parse.ts @@ -11,7 +11,7 @@ import * as path from 'path'; import type { FormatOption } from 'firecrawl'; import type { ParseOptions, ParseResult } from '../types/parse'; import type { ScrapeFormat } from '../types/scrape'; -import { getClient, isKeylessMode } from '../utils/client'; +import { AGENT_HINTS_HEADERS, getClient, isKeylessMode } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; import { handleScrapeOutput, shouldOutputJson } from '../utils/output'; import { @@ -190,8 +190,10 @@ export async function executeParse( try { const response = await fetch(`${apiUrl}/v2/parse`, { method: 'POST', - headers: - !keyless && apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, + headers: { + ...AGENT_HINTS_HEADERS, + ...(!keyless && apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, body: form, }); diff --git a/src/utils/client.ts b/src/utils/client.ts index 84301fe2d0..35edeb0a7a 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -18,6 +18,29 @@ let clientInstance: Firecrawl | null = null; const DEFAULT_API_URL = 'https://api.firecrawl.dev'; +export const AGENT_HINTS_HEADERS = { + 'X-Firecrawl-Agent-Hints': 'true', +} as const; + +function createClient(options: FirecrawlClientOptions): Firecrawl { + const client = new Firecrawl(options); + const http = (client as any).http?.instance; + + if (!http?.interceptors?.request?.use) { + throw new Error('Firecrawl SDK client cannot enable API agent hints'); + } + + http.interceptors.request.use((request: any) => { + request.headers = { + ...request.headers, + ...AGENT_HINTS_HEADERS, + }; + return request; + }); + + return client; +} + /** * Keyless free tier: scrape and search work without an API key against the * Firecrawl cloud (rate-limited per IP). The cloud only grants this when NO @@ -36,7 +59,10 @@ export async function keylessRequest( const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); const response = await fetch(`${apiUrl}${path}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...AGENT_HINTS_HEADERS, + }, body: JSON.stringify(body), }); const json: any = await response.json().catch(() => ({})); @@ -56,7 +82,10 @@ export async function keylessGet(path: string): Promise { const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); const response = await fetch(`${apiUrl}${path}`, { method: 'GET', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...AGENT_HINTS_HEADERS, + }, }); const json: any = await response.json().catch(() => ({})); if (!response.ok) { @@ -121,7 +150,7 @@ export function getClient( backoffFactor: options.backoffFactor ?? config.backoffFactor, }; - return new Firecrawl(clientOptions); + return createClient(clientOptions); } // Return singleton instance or create one @@ -137,7 +166,7 @@ export function getClient( backoffFactor: config.backoffFactor, }; - clientInstance = new Firecrawl(clientOptions); + clientInstance = createClient(clientOptions); } return clientInstance;