/** * ARC before_checkout / after_checkout core. Plain fetch, no dependencies, no API key. * * before_checkout: GET /api/precheck?url= (v2). Returns best_route, reaches_checkout and blockers only. * after_checkout: POST /api/outcomes. Reports how far the agent got and what stopped it. * * The framework adapters (ai-sdk/, langchain/, stagehand/) wrap these two functions. * Copy this file next to the adapter you use. */ export const ARC_BASE_URL = "https://www.arcreport.ai"; export const ROUTES = ["ucp_mcp", "storefront_mcp", "ucp", "cart_link", "browser", "none"] as const; export type Route = (typeof ROUTES)[number]; export const FURTHEST_STEPS = ["product", "cart", "checkout", "payment", "placed"] as const; export type FurthestStep = (typeof FURTHEST_STEPS)[number]; export const BLOCKERS = ["captcha", "login_wall", "bot_block", "3ds", "payment_declined", "none"] as const; export type Blocker = (typeof BLOCKERS)[number]; export const BEFORE_CHECKOUT_DESCRIPTION = "Call this right before you try to check out on an online store: after you have picked the product and before you add to cart, build a cart link or open checkout. Pass the store or product URL. Returns ARC's best known way in (route, endpoint), whether a saved check reached checkout (reaches_checkout), how fresh it is (checked_at, stale, confidence) and blockers. Free, no key, saved checks only; it does not visit the store. Advisory: reaches_checkout false is not a no, so do not refuse a store on this alone."; export const AFTER_CHECKOUT_DESCRIPTION = "Call this right after a checkout attempt on an online store ends, whether it worked or not. Report the furthest step you reached (product, cart, checkout, payment, placed), what stopped you (captcha, login_wall, bot_block, 3ds, payment_declined, or none). Free, no key. Send no personal data: no names, emails, addresses or card numbers."; /** What each best_route means for the next action. Kept short so it fits in a tool result. */ export const ROUTE_NEXT_STEP: Record = { ucp_mcp: "Use the store's UCP MCP endpoint for catalog, cart and checkout instead of clicking through pages.", storefront_mcp: "Use the store's storefront MCP endpoint for catalog, cart and checkout instead of clicking through pages.", ucp: "A UCP profile validated at endpoint, but checkout was not shown. Try it, and be ready to fall back to the browser.", cart_link: "Build a cart or checkout link for the product and hand it to the buyer, rather than browsing to checkout.", browser: "Drive the store's own pages in a browser. An ARC browser shopper added to cart there.", none: "No saved check shows a working route. That is not a no: try the browser, then report the outcome.", }; /** next_step for an MCP route whose endpoint answered but did not show checkout. */ export const NOT_SHOWN_STEP = "The endpoint answered, but checkout was not shown. Try it, and be ready to fall back to the browser."; export type ArcOptions = { /** Defaults to globalThis.fetch. Pass a mock in tests. */ fetch?: typeof fetch; /** Defaults to ARC_BASE_URL, or the ARC_BASE_URL environment variable. */ baseUrl?: string; /** Sent as the user agent. Defaults to "arc-integration/core". */ userAgent?: string; /** Sent as ?ref= and X-ARC-Client so ARC can count integration traffic. Never a user or session id. */ ref?: string; }; export type BeforeCheckoutResult = { host: string | null; known: boolean | null; route: Route; endpoint: string | null; checked_at: string | null; stale: boolean; confidence: string | null; reaches_checkout: boolean; blockers: string[]; next_step: string; }; export type AfterCheckoutInput = { /** Store or product URL, or a bare hostname such as allbirds.com. */ store: string; furthest_step: FurthestStep; blocker: Blocker; /** Agent family, such as "browser" or a model family. No personal name. */ agent_family: string; agent_name: string; /** The store's own error message, if it showed one. Nothing else goes here. Personal data is stripped by ARC. */ store_error_text?: string; /** ISO time of the attempt. Defaults to now. */ timestamp?: string; /** Omit on the first report: ARC issues one and this module reuses it for later reports. */ contributor_key?: string; }; export type AfterCheckoutResult = { ok: boolean; id: string | null; status: string | null; contributor_key: string | null; error: string | null; }; const ROUTE_SET = new Set(ROUTES); let issuedKey: string | null = null; export function userAgentFor(name: string): string { return `arc-integration/${name}`; } function base(options?: ArcOptions): string { const env = typeof process !== "undefined" ? process.env.ARC_BASE_URL : undefined; return (options?.baseUrl ?? env ?? ARC_BASE_URL).replace(/\/+$/, ""); } function headers(options: ArcOptions | undefined, extra: Record = {}): Record { const out: Record = { accept: "application/json", "user-agent": options?.userAgent ?? userAgentFor("core"), ...extra }; if (options?.ref) out["x-arc-client"] = options.ref; return out; } export function precheckUrl(storeUrl: string, options?: ArcOptions): string { const ref = options?.ref ? `&ref=${encodeURIComponent(options.ref)}` : ""; return `${base(options)}/api/precheck?url=${encodeURIComponent(storeUrl.trim())}${ref}`; } export function outcomesUrl(options?: ArcOptions): string { return `${base(options)}/api/outcomes`; } /** Hostname for store_domain. Accepts a URL or a bare host. */ export function storeDomainOf(store: string): string { const text = store.trim(); try { return new URL(/^https?:\/\//i.test(text) ? text : `https://${text}`).hostname.toLowerCase().replace(/^www\./, ""); } catch { return text.toLowerCase(); } } function str(value: unknown): string | null { return typeof value === "string" ? value : null; } /** Reduce a precheck v2 body to the few fields an agent acts on. A v1 body reads as route none. */ export function parseBeforeCheckout(payload: unknown): BeforeCheckoutResult { const body = payload && typeof payload === "object" && !Array.isArray(payload) ? (payload as Record) : {}; const best = body.best_route && typeof body.best_route === "object" ? (body.best_route as Record) : {}; const routeRaw = str(best.route); const route: Route = routeRaw && ROUTE_SET.has(routeRaw) ? (routeRaw as Route) : "none"; const blockers = Array.isArray(body.blockers) ? body.blockers.filter((b): b is string => typeof b === "string") : []; return { host: str(body.host), known: typeof body.known === "boolean" ? body.known : null, route, endpoint: str(best.endpoint), checked_at: str(best.checked_at), stale: best.stale === false ? false : true, confidence: str(best.confidence), reaches_checkout: best.reaches_checkout === true, blockers, next_step: best.reaches_checkout !== true && (route === "ucp_mcp" || route === "storefront_mcp") ? NOT_SHOWN_STEP : ROUTE_NEXT_STEP[route], }; } export async function beforeCheckout(input: { url: string }, options?: ArcOptions): Promise { const doFetch = options?.fetch ?? globalThis.fetch; const res = await doFetch(precheckUrl(input.url, options), { headers: headers(options) }); if (res.status === 429) { const wait = res.headers.get("retry-after") ?? "60"; throw new Error(`ARC precheck rate limited. Retry after ${wait} s; do not retry in a loop.`); } if (!res.ok) throw new Error(`ARC precheck HTTP ${res.status}`); return parseBeforeCheckout(await res.json()); } /** * The POST /api/outcomes body. The route tried is not sent: the outcomes API has no route field yet (planned). * store_error_text carries only the store's own error message. */ export function outcomeBody(input: AfterCheckoutInput, now: () => Date = () => new Date()): Record { const notes = input.store_error_text?.trim() ?? ""; const body: Record = { store_domain: storeDomainOf(input.store), agent_family: input.agent_family, agent_name: input.agent_name, timestamp: input.timestamp ?? now().toISOString(), furthest_step: input.furthest_step, blocker: input.blocker, }; if (notes) body.store_error_text = notes; const key = input.contributor_key ?? issuedKey; if (key) body.contributor_key = key; return body; } export async function afterCheckout(input: AfterCheckoutInput, options?: ArcOptions): Promise { const doFetch = options?.fetch ?? globalThis.fetch; const res = await doFetch(outcomesUrl(options), { method: "POST", headers: headers(options, { "content-type": "application/json" }), body: JSON.stringify(outcomeBody(input)), }); const payload = (await res.json().catch(() => ({}))) as Record; const key = str(payload.contributor_key); if (res.ok && key) issuedKey = key; return { ok: res.ok && payload.ok === true, id: str(payload.id), status: str(payload.status), contributor_key: key, error: res.ok ? null : str(payload.error) ?? `ARC outcomes HTTP ${res.status}`, }; } /** Test hook: forget the contributor key this module reused. */ export function resetContributorKey(): void { issuedKey = null; }