diff --git a/.env.example b/.env.example
index ce24e20..27ccad9 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,7 @@ EXPO_PUBLIC_CEREBRAS_API_KEY=csk-REPLACE_ME
# Optional tools:
# EXPO_PUBLIC_EXA_API_KEY= # web_search, else the model invents data
# EXPO_PUBLIC_UNSPLASH_ACCESS_KEY= # real photos, else LoremFlickr
+# EXPO_PUBLIC_FIRECRAWL_API_KEY= # local dev only; public/bundled, never a shared key
# Optional model overrides (any OpenAI-compatible endpoint):
# EXPO_PUBLIC_CEREBRAS_BASE_URL=https://api.cerebras.ai/v1
diff --git a/App.tsx b/App.tsx
index f871dfd..c654497 100644
--- a/App.tsx
+++ b/App.tsx
@@ -1,18 +1,41 @@
import { StatusBar } from "expo-status-bar";
+import {
+ Inter_300Light,
+ Inter_400Regular,
+ Inter_500Medium,
+ Inter_600SemiBold,
+ Inter_700Bold,
+ Inter_800ExtraBold,
+ useFonts,
+} from "@expo-google-fonts/inter";
import React, { useEffect } from "react";
import { SafeAreaProvider } from "react-native-safe-area-context";
import GenOS from "./src/genos/GenOS";
import { initTelemetry } from "./src/genos/telemetry";
+import { TypographyProvider } from "./src/genos/typography";
export default function App() {
+ const [fontsLoaded, fontError] = useFonts({
+ Inter_300Light,
+ Inter_400Regular,
+ Inter_500Medium,
+ Inter_600SemiBold,
+ Inter_700Bold,
+ Inter_800ExtraBold,
+ });
+
useEffect(() => {
initTelemetry();
}, []);
+ if (!fontsLoaded && !fontError) return null;
+
return (
-
-
-
-
+
+
+
+
+
+
);
}
diff --git a/README.md b/README.md
index 6118b55..827ffce 100644
--- a/README.md
+++ b/README.md
@@ -69,10 +69,18 @@ To skip the key prompt during development, or to turn on optional tools, drop a
EXPO_PUBLIC_CEREBRAS_API_KEY=csk-... # skip the first-launch key screen
# EXPO_PUBLIC_EXA_API_KEY= # enable the web_search tool (Exa)
# EXPO_PUBLIC_UNSPLASH_ACCESS_KEY= # real semantic photos (else LoremFlickr)
+# EXPO_PUBLIC_FIRECRAWL_API_KEY= # local dev only; bundled into the app
# EXPO_PUBLIC_GENOS_MODEL=gemma-4-31b
# EXPO_PUBLIC_CEREBRAS_BASE_URL= # any OpenAI-compatible endpoint
```
+Firecrawl is bring-your-own-key and optional. When a trusted Firecrawl workflow
+needs it, AppLess asks for that provider's key and stores it separately on the
+device (Keychain / Keystore, or localStorage on web). Missing or rejected
+Firecrawl credentials do not affect ordinary AppLess, Cerebras generation, or
+Exa search. Never ship a shared production credential through an
+`EXPO_PUBLIC_*` variable: Expo inlines public variables into the client bundle.
+
> Expo SDK 54 is pinned to match the Expo Go build on the App Store and Play
> Store. If your Expo Go reports a different SDK, run
> `npx expo install expo@^ && npx expo install --fix`.
@@ -105,6 +113,62 @@ places), the model calls a `web_search` tool ([Exa](https://exa.ai)) mid-stream
and composes the screen from the results, sources cited. Images are referenced
as semantic queries the app resolves on-device.
+The optional Firecrawl runtime adds bounded v2 REST primitives for search,
+single-page scrape, and asynchronous Agent jobs. Agent runs always use a
+workflow-owned schema, an explicit central credit ceiling, a depth-specific
+timeout, status polling, and cooperative cancellation. Firecrawl can consume
+paid credits, and cancellation may accrue a small amount while an in-flight
+step reaches a stopping point. Results are capped before returning to the model
+while source URLs and recoverable Agent job IDs are retained.
+
+### Firecrawl slash commands
+
+Type `/` in the home ask field to open the Firecrawl command menu. Partial text
+such as `/fir` or `/lead` filters the menu only; execution requires an exact
+slash ID. A command can include an initial argument, for example
+`/firecrawl https://example.com` or
+`/firecrawl-lead-research Small public company`. Unknown slash commands are
+never sent to the generic model route.
+
+The enabled catalog is:
+
+- `/firecrawl`
+- `/firecrawl-company-directories`
+- `/firecrawl-competitive-intel`
+- `/firecrawl-dashboard-reporting`
+- `/firecrawl-deep-research`
+- `/firecrawl-demo-walkthrough`
+- `/firecrawl-knowledge-base`
+- `/firecrawl-knowledge-ingest`
+- `/firecrawl-lead-gen`
+- `/firecrawl-lead-research`
+- `/firecrawl-market-research`
+- `/firecrawl-qa`
+- `/firecrawl-research-papers`
+- `/firecrawl-seo-audit`
+- `/firecrawl-shop`
+- `/firecrawl-website-design-clone`
+- `/firecrawl-workflows`
+
+Every command is BYOK-gated and opens a deterministic setup form before any
+Firecrawl request. The form validates required text, URL, bounded number,
+selection, and confirmation inputs locally. It shows the central maximum credit
+budget for the selected workflow/depth and requires explicit confirmation;
+Agent tasks also enforce that confirmation in the runtime. Maximums are credit
+ceilings, not expected spend: most workflows cap at 150 credits, competitive
+and market research at 250, and deep research at 100 quick, 300 thorough, or
+750 exhaustive credits. Base URL/query work still requires confirmation before
+its paid scrape/search request starts.
+
+Results are in-app progress, summary, structured table/list, data-gap, and
+source-link views. AppLess does **not** currently export CSV/JSON/files, upload
+source files, write to a CRM, send outreach, make purchases, or perform
+destructive submissions. Authenticated dashboards work only inside an already
+authorized session boundary; login walls, CAPTCHAs, robots restrictions, and
+paywalls are not bypassed. Firecrawl Agent is a Research Preview API, so its
+isolated protocol adapter should be rechecked against the official v2
+documentation before release.
+
## Packages
AppLess is a thin, native shell over the OpenUI runtime. The heavy lifting is
@@ -124,7 +188,8 @@ the OpenUI stack:
| [`@react-native-community/slider`](https://github.com/callstack/react-native-slider) | Native slider input for forms. |
Screens are generated by [Cerebras](https://cloud.cerebras.ai); live search is
-powered by [Exa](https://exa.ai). Both are bring-your-own-key.
+powered by [Exa](https://exa.ai), with optional bounded research through
+[Firecrawl](https://firecrawl.dev). Every provider is bring-your-own-key.
## Design systems
diff --git a/__tests__/commands.test.tsx b/__tests__/commands.test.tsx
new file mode 100644
index 0000000..76f38e2
--- /dev/null
+++ b/__tests__/commands.test.tsx
@@ -0,0 +1,209 @@
+import React from "react";
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import {
+ FIRECRAWL_COMMANDS,
+ MAX_COMMAND_INPUT,
+ commandAvailability,
+ commandToApp,
+ filterSlashCommands,
+ parseSlashCommand,
+} from "../src/genos/commands";
+import { CommandMenu, moveCommandSelection } from "../src/genos/shell/CommandMenu";
+import { WorkflowSetup, initialWorkflowValues } from "../src/genos/shell/WorkflowSetup";
+import {
+ FIRECRAWL_WORKFLOWS,
+ FIRECRAWL_WORKFLOW_IDS,
+ FIRECRAWL_WORKFLOW_SETUPS,
+ setupCreditBudget,
+ validateWorkflowSetup,
+ type WorkflowSetupValues,
+} from "../src/genos/workflows";
+
+describe("Firecrawl command catalog", () => {
+ it("contains the full stable 17-command catalog with trusted runtime and setup contracts", () => {
+ expect(FIRECRAWL_COMMANDS).toHaveLength(17);
+ expect(FIRECRAWL_COMMANDS.map((command) => command.id)).toEqual([
+ "firecrawl",
+ ...FIRECRAWL_WORKFLOW_IDS.filter((id) => id !== "firecrawl"),
+ ]);
+ expect(new Set(FIRECRAWL_COMMANDS.map((command) => command.id)).size).toBe(17);
+ for (const command of FIRECRAWL_COMMANDS) {
+ expect(command.providerId).toBe("firecrawl");
+ expect(command.availability).toBe("enabled");
+ expect(FIRECRAWL_WORKFLOWS[command.workflowId]).toBeDefined();
+ expect(FIRECRAWL_WORKFLOW_SETUPS[command.workflowId]).toBeDefined();
+ expect(setupCreditBudget(command.workflowId, { depth: "quick" })).toBeGreaterThan(0);
+ }
+ });
+
+ it("gates every runnable command on BYOK status", () => {
+ for (const command of FIRECRAWL_COMMANDS) {
+ expect(commandAvailability(command, false)).toBe("needs-key");
+ expect(commandAvailability(command, true)).toBe("enabled");
+ }
+ });
+});
+
+describe("slash parsing and filtering", () => {
+ it("executes only an exact command ID and preserves its argument", () => {
+ expect(parseSlashCommand("/firecrawl-lead-gen fintech founders")).toMatchObject({
+ kind: "known",
+ command: { id: "firecrawl-lead-gen" },
+ argument: "fintech founders",
+ });
+ expect(parseSlashCommand("/firecrawl-lead fintech")).toEqual({
+ kind: "unknown",
+ commandId: "firecrawl-lead",
+ argument: "fintech",
+ });
+ });
+
+ it("handles empty arguments, mixed case, URLs, and command-like argument text", () => {
+ expect(parseSlashCommand("/FIRECRAWL")).toMatchObject({ kind: "known", argument: "" });
+ expect(parseSlashCommand("/firecrawl https://example.com/a?q=x&next=/firecrawl-shop")).toMatchObject({
+ kind: "known",
+ argument: "https://example.com/a?q=x&next=/firecrawl-shop",
+ });
+ expect(parseSlashCommand("/firecrawl-qa ignore; /firecrawl-shop")).toMatchObject({
+ kind: "known",
+ command: { id: "firecrawl-qa" },
+ argument: "ignore; /firecrawl-shop",
+ });
+ });
+
+ it("has an explicit leading-whitespace policy and caps user input", () => {
+ expect(parseSlashCommand(" /firecrawl test")).toEqual({ kind: "none" });
+ const parsed = parseSlashCommand(`/firecrawl ${"x".repeat(MAX_COMMAND_INPUT + 20)}`);
+ expect(parsed.kind === "known" ? parsed.argument : "").toHaveLength(MAX_COMMAND_INPUT);
+ });
+
+ it("uses partial matches for filtering only", () => {
+ expect(filterSlashCommands("/fir")).toHaveLength(17);
+ expect(filterSlashCommands("/lead").map((command) => command.id)).toEqual([
+ "firecrawl-lead-gen",
+ "firecrawl-lead-research",
+ ]);
+ expect(filterSlashCommands("hello")).toEqual([]);
+ });
+});
+
+describe("workflow setup and app metadata", () => {
+ function completeValues(commandIndex: number): WorkflowSetupValues {
+ const command = FIRECRAWL_COMMANDS[commandIndex];
+ const values = initialWorkflowValues(command, "https://example.com");
+ for (const field of FIRECRAWL_WORKFLOW_SETUPS[command.workflowId].fields) {
+ if (field.type === "checkbox") values[field.id] = true;
+ else if (field.type === "number") values[field.id] = field.min ?? 1;
+ else if (field.type === "select") values[field.id] = field.options?.[0]?.value ?? "quick";
+ else if (field.type === "url") values[field.id] = "https://example.com";
+ else if (!values[field.id]) values[field.id] = "bounded public scope";
+ }
+ return values;
+ }
+
+ it("has deterministic valid typed inputs for every enabled workflow", () => {
+ FIRECRAWL_COMMANDS.forEach((command, index) => {
+ expect(validateWorkflowSetup(command.workflowId, completeValues(index))).toEqual({});
+ });
+ });
+
+ it("requires the explicit credit confirmation and validates URLs and bounds locally", () => {
+ expect(validateWorkflowSetup("firecrawl-deep-research", { topic: "AI", depth: "quick" })).toHaveProperty("confirmCredits");
+ expect(validateWorkflowSetup("firecrawl-seo-audit", {
+ siteUrl: "file:///private/data",
+ boundary: "one section",
+ pageCap: 101,
+ depth: "quick",
+ confirmCredits: true,
+ })).toMatchObject({ siteUrl: expect.any(String), pageCap: expect.any(String) });
+ const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === "firecrawl-deep-research")!;
+ expect(() => commandToApp(command, "AI", { topic: "AI", depth: "quick" })).toThrow(
+ "Workflow setup must be complete before launch",
+ );
+ });
+
+ it("creates a stable provider/workflow app without secrets or export claims", () => {
+ const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === "firecrawl-lead-research")!;
+ const values = { ...completeValues(FIRECRAWL_COMMANDS.indexOf(command)), apiKey: "must-not-leak" };
+ const first = commandToApp(command, "Small public company", values);
+ const second = commandToApp(command, "Small public company", values);
+ expect(first.id).toBe(second.id);
+ expect(first).toMatchObject({ providerId: "firecrawl", workflowId: command.id });
+ expect(first.request).toContain("Small public company");
+ expect(first.request).not.toContain("must-not-leak");
+ expect(first.request).toContain("Do not claim");
+ });
+});
+
+describe("CommandMenu and WorkflowSetup rendering", () => {
+ let tree: ReactTestRenderer | undefined;
+ afterEach(() => {
+ if (tree) act(() => tree?.unmount());
+ tree = undefined;
+ });
+
+ function renderMenu(text: string, hasProviderKey: boolean, onSelect = jest.fn(), onNeedsKey = jest.fn()) {
+ act(() => {
+ tree = create(
+ ,
+ );
+ });
+ return { onSelect, onNeedsKey };
+ }
+
+ it("renders filtered, selected, key-required, empty, and accessible states", () => {
+ const callbacks = renderMenu("/lead", false);
+ const rows = tree!.root.findAll((node) => node.props.accessibilityLabel?.startsWith("/firecrawl-lead") && typeof node.props.onPress === "function");
+ expect(new Set(rows.map((row) => row.props.accessibilityLabel)).size).toBe(2);
+ expect(rows[0].props.accessibilityState).toMatchObject({ selected: true, disabled: false });
+ expect(rows[0].props.accessibilityLabel).toContain("Connect key");
+ act(() => rows[0].props.onPress());
+ expect(callbacks.onNeedsKey).toHaveBeenCalledWith(expect.objectContaining({ id: "firecrawl-lead-gen" }));
+ act(() => tree!.unmount());
+ tree = undefined;
+ renderMenu("/not-a-command", true);
+ expect(tree!.root.findByProps({ accessibilityLabel: "No matching slash commands" })).toBeTruthy();
+ });
+
+ it("supports deterministic up/down wrapping and enabled tap selection", () => {
+ expect(moveCommandSelection(0, -1, 17)).toBe(16);
+ expect(moveCommandSelection(16, 1, 17)).toBe(0);
+ const callbacks = renderMenu("/lead", true);
+ const row = tree!.root.findAll((node) => node.props.accessibilityLabel?.startsWith("/firecrawl-lead") && typeof node.props.onPress === "function")[0];
+ act(() => row.props.onPress());
+ expect(callbacks.onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "firecrawl-lead-gen" }));
+ });
+
+ it("renders unavailable rows as disabled with an explanation", () => {
+ const command = FIRECRAWL_COMMANDS[0] as { availability: "enabled" | "unavailable" };
+ command.availability = "unavailable";
+ try {
+ renderMenu("/firecrawl", true);
+ const row = tree!.root.findAll((node) => node.props.accessibilityLabel?.startsWith("/firecrawl,") && typeof node.props.onPress === "function")[0];
+ expect(row.props.accessibilityState.disabled).toBe(true);
+ expect(row.props.accessibilityLabel).toContain("Unavailable");
+ } finally {
+ command.availability = "enabled";
+ }
+ });
+
+ it("does not submit incomplete onboarding or make a provider call", () => {
+ const onSubmit = jest.fn();
+ const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === "firecrawl-deep-research")!;
+ act(() => {
+ tree = create();
+ });
+ const run = tree!.root.findByProps({ accessibilityLabel: "Run with maximum 100 credits" });
+ act(() => run.props.onPress());
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(JSON.stringify(tree!.toJSON())).toContain("is required");
+ });
+});
diff --git a/__tests__/firecrawl.test.ts b/__tests__/firecrawl.test.ts
new file mode 100644
index 0000000..77c9e56
--- /dev/null
+++ b/__tests__/firecrawl.test.ts
@@ -0,0 +1,288 @@
+jest.mock("expo-secure-store", () => ({
+ getItemAsync: jest.fn(async () => null),
+ setItemAsync: jest.fn(async () => {}),
+ deleteItemAsync: jest.fn(async () => {}),
+}));
+jest.mock("expo/fetch", () => ({ fetch: jest.fn() }));
+
+import { fetch as expoFetch } from "expo/fetch";
+import React from "react";
+import renderer, { act } from "react-test-renderer";
+import { cerebrasKey } from "../src/config";
+import { ProviderKeyGate } from "../src/genos/shell/ProviderKeyGate";
+import {
+ cancelFirecrawlAgent,
+ firecrawlAgent,
+ firecrawlScrape,
+ firecrawlSearch,
+ getFirecrawlAgentStatus,
+ pollFirecrawlAgent,
+} from "../src/genos/tools/firecrawl";
+import {
+ FirecrawlKeyStore,
+ firecrawlKey,
+} from "../src/genos/providers/firecrawl/key";
+import {
+ agentPolicy,
+ FIRECRAWL_WORKFLOW_IDS,
+ FIRECRAWL_WORKFLOWS,
+ trustedAgentSchema,
+} from "../src/genos/workflows";
+
+const mockedFetch = expoFetch as jest.MockedFunction;
+
+function response(status: number, body: unknown) {
+ return {
+ status,
+ ok: status >= 200 && status < 300,
+ json: jest.fn(async () => body),
+ text: jest.fn(async () => JSON.stringify(body)),
+ } as unknown as Response;
+}
+
+describe("Firecrawl BYOK store", () => {
+ it("hydrates, sets, and rejects only the current key", async () => {
+ const storage = {
+ read: jest.fn(async () => "fc-stored"),
+ write: jest.fn(async (_value: string | null) => {}),
+ };
+ const store = new FirecrawlKeyStore(storage, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(store.get()).toBe("fc-stored");
+ expect(store.getStatus()).toBe("present");
+
+ await store.set("fc-first");
+ store.markRejected("fc-stale");
+ expect(store.get()).toBe("fc-first");
+ await store.set("fc-replaced");
+ store.markRejected("fc-first");
+ expect(store.get()).toBe("fc-replaced");
+ store.markRejected("fc-replaced");
+ expect(store.get()).toBeNull();
+ expect(store.getStatus()).toBe("rejected");
+ expect(storage.write).toHaveBeenLastCalledWith(null);
+ });
+
+ it("dismisses the provider gate without changing Cerebras credentials", async () => {
+ const dismiss = jest.fn();
+ const before = cerebrasKey.get();
+ let tree!: renderer.ReactTestRenderer;
+ await act(async () => {
+ tree = renderer.create(
+ React.createElement(ProviderKeyGate, {
+ status: "missing",
+ onDismiss: dismiss,
+ onConnected: jest.fn(),
+ }),
+ );
+ });
+ await act(async () => {
+ tree.root.findByProps({ accessibilityLabel: "Dismiss Firecrawl key prompt" }).props.onPress();
+ });
+ expect(dismiss).toHaveBeenCalledTimes(1);
+ expect(cerebrasKey.get()).toBe(before);
+ await act(async () => tree.unmount());
+ });
+});
+
+describe("trusted Firecrawl workflow contracts", () => {
+ it.each(FIRECRAWL_WORKFLOW_IDS)("defines bounded policy and schema for %s", (id) => {
+ const contract = FIRECRAWL_WORKFLOWS[id];
+ const policy = agentPolicy(id, "quick");
+ const schema = trustedAgentSchema(id);
+ expect(contract.instructions.length).toBeGreaterThan(30);
+ expect(contract.resultFields.length).toBeGreaterThan(0);
+ expect(policy?.maxCredits).toBeGreaterThan(0);
+ expect(policy?.timeoutMs).toBeGreaterThan(0);
+ expect(schema).toMatchObject({ type: "object", additionalProperties: false });
+ expect(Object.values((schema?.properties ?? {}) as Record)).not.toContainEqual({});
+ });
+
+ it("keeps deep-research budgets centralized by depth", () => {
+ expect(agentPolicy("firecrawl-deep-research", "quick")?.maxCredits).toBe(100);
+ expect(agentPolicy("firecrawl-deep-research", "thorough")?.maxCredits).toBe(300);
+ expect(agentPolicy("firecrawl-deep-research", "exhaustive")?.maxCredits).toBe(750);
+ });
+});
+
+describe("Firecrawl REST primitives", () => {
+ beforeEach(async () => {
+ jest.useRealTimers();
+ mockedFetch.mockReset();
+ await firecrawlKey.set("fc-unit-test-placeholder");
+ });
+
+ it("validates arguments before network access", async () => {
+ expect(await firecrawlSearch({ query: "" })).toContain("ERROR");
+ expect(await firecrawlScrape({ url: "file:///etc/passwd" }, "firecrawl")).toContain("HTTP(S)");
+ expect(
+ await firecrawlAgent({ prompt: "x" }, undefined),
+ ).toContain("trusted Firecrawl workflow");
+ expect(mockedFetch).not.toHaveBeenCalled();
+ });
+
+ it("posts a bounded search request and retains source URLs", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ response(200, {
+ success: true,
+ data: { web: [{ title: "Docs", url: "https://docs.firecrawl.dev/", description: "API" }] },
+ creditsUsed: 2,
+ }) as never,
+ );
+ const output = await firecrawlSearch({ query: "Firecrawl API", limit: 999, format: "markdown" });
+ const [url, init] = mockedFetch.mock.calls[0];
+ expect(url).toBe("https://api.firecrawl.dev/v2/search");
+ expect(init?.method).toBe("POST");
+ const headers = init?.headers as Record;
+ expect(headers.Authorization).toMatch(/^Bearer /);
+ const body = JSON.parse(String(init?.body));
+ expect(body.limit).toBe(10);
+ expect(body.scrapeOptions.formats).toEqual([{ type: "markdown" }]);
+ expect(output).toContain("https://docs.firecrawl.dev/");
+ });
+
+ it("uses a trusted scrape schema, truncates content, and retains the source", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ response(200, {
+ data: {
+ markdown: "x".repeat(30_000),
+ metadata: { sourceURL: "https://example.com/article" },
+ },
+ }) as never,
+ );
+ const output = await firecrawlScrape(
+ { url: "https://example.com/article", format: "markdown" },
+ "firecrawl-market-research",
+ );
+ expect(output.length).toBeLessThan(25_000);
+ expect(output).toContain("https://example.com/article");
+ expect(output).toContain("truncated");
+ });
+
+ it("rejects the exact credential on 401 and maps rate/credit errors", async () => {
+ mockedFetch.mockResolvedValueOnce(response(401, { error: "unauthorized" }) as never);
+ expect(await firecrawlSearch({ query: "test" })).toContain("rejected the API key");
+ expect(firecrawlKey.getStatus()).toBe("rejected");
+
+ await firecrawlKey.set("fc-unit-test-placeholder");
+ mockedFetch.mockResolvedValueOnce(response(429, { error: "rate" }) as never);
+ expect(await firecrawlSearch({ query: "test" })).toContain("rate or concurrency limit");
+
+ mockedFetch.mockResolvedValueOnce(response(402, { error: "credits" }) as never);
+ expect(await firecrawlSearch({ query: "test" })).toContain("credit limit reached");
+ });
+
+ it("starts Agent once with the central maxCredits and trusted schema, then retains sources", async () => {
+ mockedFetch
+ .mockResolvedValueOnce(response(200, { success: true, id: "agent-job-1234" }) as never)
+ .mockResolvedValueOnce(
+ response(200, {
+ success: true,
+ status: "completed",
+ data: { finding: "Observed", sources: ["https://example.com/evidence"] },
+ creditsUsed: 12,
+ }) as never,
+ );
+ const output = await firecrawlAgent(
+ { prompt: "Compare this market", depth: "quick", confirmCost: true, schema: { malicious: true }, maxCredits: 999999 },
+ "firecrawl-market-research",
+ );
+ const post = mockedFetch.mock.calls.find(([, init]) => init?.method === "POST");
+ const body = JSON.parse(String(post?.[1]?.body));
+ expect(body.maxCredits).toBe(250);
+ expect(body.model).toBe("spark-1-mini");
+ expect(body.schema).toEqual(trustedAgentSchema("firecrawl-market-research"));
+ expect(body.schema).not.toHaveProperty("malicious");
+ expect(output).toContain("agent-job-1234");
+ expect(output).toContain("https://example.com/evidence");
+ });
+
+ it("resumes by job ID without creating a duplicate paid job", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ response(200, { success: true, status: "completed", data: {}, creditsUsed: 4 }) as never,
+ );
+ await firecrawlAgent(
+ { prompt: "Resume", depth: "quick", confirmCost: true, jobId: "existing-job-1234" },
+ "firecrawl-market-research",
+ );
+ expect(mockedFetch).toHaveBeenCalledTimes(1);
+ expect(mockedFetch.mock.calls[0][1]?.method).toBe("GET");
+ });
+
+ it("deduplicates identical Agent starts across screen retries", async () => {
+ mockedFetch
+ .mockResolvedValueOnce(response(200, { success: true, id: "dedupe-job-1234" }) as never)
+ .mockResolvedValueOnce(response(200, { success: true, status: "completed", data: {} }) as never)
+ .mockResolvedValueOnce(response(200, { success: true, status: "completed", data: {} }) as never);
+ const args = { prompt: "Unique dedupe test prompt", depth: "quick", confirmCost: true };
+ await firecrawlAgent(args, "firecrawl-market-research");
+ await firecrawlAgent(args, "firecrawl-market-research");
+ expect(mockedFetch.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1);
+ expect(mockedFetch.mock.calls.filter(([, init]) => init?.method === "GET")).toHaveLength(2);
+ });
+
+ it("maps failed and cancelled status distinctly", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ response(200, { success: true, status: "failed", error: "could not extract" }) as never,
+ );
+ expect(await getFirecrawlAgentStatus("failed-job-1234")).toMatchObject({ status: "failed" });
+ mockedFetch.mockResolvedValueOnce(
+ response(200, { success: true, status: "cancelled" }) as never,
+ );
+ expect(await getFirecrawlAgentStatus("cancel-job-1234")).toMatchObject({ status: "cancelled" });
+ });
+
+ it("polls processing to completed with progress", async () => {
+ jest.useFakeTimers();
+ mockedFetch
+ .mockResolvedValueOnce(response(200, { success: true, status: "processing" }) as never)
+ .mockResolvedValueOnce(response(200, { success: true, status: "completed", data: {} }) as never);
+ const progress = jest.fn();
+ const pending = pollFirecrawlAgent("poll-job-1234", 30_000, undefined, progress);
+ await Promise.resolve();
+ await jest.advanceTimersByTimeAsync(3_000);
+ await expect(pending).resolves.toMatchObject({ status: "completed" });
+ expect(progress).toHaveBeenCalledWith(expect.objectContaining({ state: "processing" }));
+ });
+
+ it("times out with a recoverable job ID and does not POST", async () => {
+ const now = jest.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValueOnce(2);
+ await expect(pollFirecrawlAgent("timeout-job-1234", 1)).rejects.toThrow("timeout-job-1234");
+ expect(mockedFetch).not.toHaveBeenCalled();
+ now.mockRestore();
+ });
+
+ it("issues documented DELETE cancellation on AbortSignal after start", async () => {
+ const controller = new AbortController();
+ mockedFetch
+ .mockResolvedValueOnce(response(200, { success: true, id: "abort-job-1234" }) as never)
+ .mockResolvedValueOnce(response(200, { success: true, status: "processing" }) as never)
+ .mockResolvedValueOnce(response(200, { success: true }) as never);
+ const output = await firecrawlAgent(
+ { prompt: "Bounded research", depth: "quick", confirmCost: true },
+ "firecrawl-market-research",
+ controller.signal,
+ (progress) => {
+ if (progress.state === "processing") controller.abort();
+ },
+ );
+ expect(output).toContain("cancelled locally");
+ expect(mockedFetch.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(true);
+ });
+
+ it("refuses every Agent run without explicit central cost confirmation", async () => {
+ const output = await firecrawlAgent(
+ { prompt: "Do not start", depth: "quick" },
+ "firecrawl-market-research",
+ );
+ expect(output).toContain("explicit Agent cost/depth confirmation");
+ expect(mockedFetch).not.toHaveBeenCalled();
+ });
+
+ it("exposes the standalone cancellation primitive", async () => {
+ mockedFetch.mockResolvedValueOnce(response(200, { success: true }) as never);
+ await cancelFirecrawlAgent("cancel-job-1234");
+ expect(mockedFetch.mock.calls[0][1]?.method).toBe("DELETE");
+ });
+});
diff --git a/__tests__/providers.test.tsx b/__tests__/providers.test.tsx
new file mode 100644
index 0000000..6f4e6f3
--- /dev/null
+++ b/__tests__/providers.test.tsx
@@ -0,0 +1,69 @@
+import { Globe } from "phosphor-react-native";
+import React from "react";
+import { Image } from "react-native";
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { APPS } from "../src/genos/apps";
+import { getProvider, raycastFaviconUrl } from "../src/genos/providers";
+import { ProviderIcon } from "../src/genos/ui/ProviderIcon";
+
+describe("provider registry and favicon resolver", () => {
+ it("normalizes a bare domain and full URL to the hostname", () => {
+ expect(raycastFaviconUrl("firecrawl.dev")).toBe(
+ "https://api.ray.so/favicon?url=firecrawl.dev&size=64",
+ );
+ expect(raycastFaviconUrl("https://www.firecrawl.dev/docs?q=icons#usage")).toBe(
+ "https://api.ray.so/favicon?url=www.firecrawl.dev&size=64",
+ );
+ });
+
+ it("removes paths and queries from bare domains", () => {
+ expect(raycastFaviconUrl("firecrawl.dev/docs?source=app", 32)).toBe(
+ "https://api.ray.so/favicon?url=firecrawl.dev&size=32",
+ );
+ });
+
+ it("defaults and clamps favicon sizes", () => {
+ expect(raycastFaviconUrl("firecrawl.dev")).toContain("&size=64");
+ expect(raycastFaviconUrl("firecrawl.dev", 1)).toContain("&size=16");
+ expect(raycastFaviconUrl("firecrawl.dev", 999)).toContain("&size=256");
+ });
+
+ it("returns null for malformed or unsupported input", () => {
+ expect(raycastFaviconUrl("not a url")).toBeNull();
+ expect(raycastFaviconUrl("http://firecrawl.dev")).toBeNull();
+ expect(raycastFaviconUrl("")).toBeNull();
+ });
+
+ it("looks up known providers without inventing unknown metadata", () => {
+ expect(getProvider("firecrawl")).toMatchObject({
+ name: "Firecrawl",
+ domain: "firecrawl.dev",
+ });
+ expect(getProvider("unknown")).toBeUndefined();
+ expect(getProvider("toString")).toBeUndefined();
+ });
+
+ it("keeps built-in app metadata provider-free", () => {
+ expect(APPS).not.toHaveLength(0);
+ expect(APPS.every((app) => app.providerId === undefined)).toBe(true);
+ });
+});
+
+describe("ProviderIcon", () => {
+ it("shows a local fallback while loading and after an image error", () => {
+ let tree!: ReactTestRenderer;
+ act(() => {
+ tree = create();
+ });
+
+ expect(tree.root.findAllByType(Globe)).toHaveLength(1);
+ const image = tree.root.findByType(Image);
+ expect(image.props.accessibilityLabel).toBe("Firecrawl provider");
+
+ act(() => image.props.onError());
+ expect(tree.root.findAllByType(Image)).toHaveLength(0);
+ expect(tree.root.findAllByType(Globe)).toHaveLength(1);
+
+ act(() => tree.unmount());
+ });
+});
diff --git a/__tests__/store.test.ts b/__tests__/store.test.ts
index a91ae70..70e340a 100644
--- a/__tests__/store.test.ts
+++ b/__tests__/store.test.ts
@@ -11,11 +11,11 @@ jest.mock("expo-secure-store", () => ({
jest.mock("expo/fetch", () => ({ fetch: jest.fn() }));
// Capture stream launches instead of hitting Cerebras.
-const streamCalls: Array<{ messages: unknown }> = [];
+const streamCalls: Array<{ messages: unknown; handlers?: Record }> = [];
jest.mock("../src/genos/stream", () => ({
NEEDS_LIVE_DATA: "needs live data",
- streamScreen: jest.fn((messages: unknown) => {
- streamCalls.push({ messages });
+ streamScreen: jest.fn((messages: unknown, handlers?: Record) => {
+ streamCalls.push({ messages, handlers });
}),
}));
@@ -23,10 +23,15 @@ import {
cleanLang,
extractActions,
openApp,
+ openDeepLink,
parseOsCommand,
resolveAction,
+ retryScreen,
screenStore,
+ setActiveScreen,
} from "../src/genos/store";
+import { firecrawlKey } from "../src/genos/providers/firecrawl/key";
+import { FIRECRAWL_COMMANDS, commandToApp } from "../src/genos/commands";
describe("cleanLang", () => {
it("strips a wrapping markdown fence", () => {
@@ -118,3 +123,82 @@ describe("resolveAction cache", () => {
expect(retried?.speculative).toBe(false);
});
});
+
+describe("trusted workflow context", () => {
+ const app = {
+ id: "research-test",
+ name: "Research",
+ emoji: "🔎",
+ tile: ["#000", "#111"] as [string, string],
+ request: "Research a market",
+ workflowId: "firecrawl-market-research",
+ };
+
+ beforeAll(async () => {
+ await firecrawlKey.set("fc-disposable-unit-test-only");
+ });
+
+ it("survives root, action child, form child, prefetch, and retry", () => {
+ const root = openApp(app);
+ expect(screenStore.get(root)?.workflowId).toBe(app.workflowId);
+ expect(streamCalls.at(-1)?.handlers?.workflowId).toBe(app.workflowId);
+
+ const child = resolveAction(root, "Compare vendors");
+ const form = resolveAction(root, "Run scoped report", { region: "US" });
+ const deep = openDeepLink("notes", "Save the sourced summary", app.workflowId);
+ expect(screenStore.get(child)?.workflowId).toBe(app.workflowId);
+ expect(screenStore.get(form)?.workflowId).toBe(app.workflowId);
+ expect(screenStore.get(deep)?.workflowId).toBe(app.workflowId);
+
+ screenStore.patch(root, {
+ status: "done",
+ content: 'root = Button("More", Action([@ToAssistant("Open evidence")]))',
+ });
+ setActiveScreen(root);
+ const prefetched = screenStore.all().find((screen) => screen.parentId === root && screen.speculative);
+ expect(prefetched?.workflowId).toBe(app.workflowId);
+
+ const callsBefore = streamCalls.length;
+ retryScreen(child);
+ expect(screenStore.get(child)?.workflowId).toBe(app.workflowId);
+ expect(streamCalls).toHaveLength(callsBefore + 1);
+ expect(streamCalls.at(-1)?.handlers?.workflowId).toBe(app.workflowId);
+ });
+
+ it("does not execute any tool round during speculative prefetch", () => {
+ const root = openApp({ ...app, id: "prefetch-safety-test" });
+ screenStore.patch(root, {
+ status: "done",
+ content: 'root = Button("Paid", Action([@ToAssistant("Run paid research")]))',
+ });
+ setActiveScreen(root);
+ const handler = streamCalls.at(-1)?.handlers?.onToolRound as (() => string) | undefined;
+ expect(handler?.()).toBe("abort");
+ });
+
+ it("launches a configured slash workflow once with provider/workflow metadata", () => {
+ const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === "firecrawl-lead-research")!;
+ const app = commandToApp(command, "Acme public company", {
+ company: "Acme public company",
+ meetingContext: "Intro meeting",
+ depth: "quick",
+ confirmCredits: true,
+ });
+ const before = streamCalls.length;
+ const root = openApp(app);
+ expect(streamCalls).toHaveLength(before + 1);
+ expect(streamCalls.at(-1)?.handlers).toMatchObject({
+ workflowId: "firecrawl-lead-research",
+ firecrawlConfirmed: true,
+ firecrawlOperation: "agent",
+ });
+ expect(screenStore.get(root)).toMatchObject({
+ appId: app.id,
+ workflowId: "firecrawl-lead-research",
+ firecrawlConfirmed: true,
+ workflowInputs: expect.objectContaining({ confirmCredits: true }),
+ request: expect.stringContaining("Acme public company"),
+ });
+ expect(app.providerId).toBe("firecrawl");
+ });
+});
diff --git a/__tests__/tools.test.ts b/__tests__/tools.test.ts
index 6ca5158..491c52e 100644
--- a/__tests__/tools.test.ts
+++ b/__tests__/tools.test.ts
@@ -10,6 +10,12 @@ jest.mock("expo/fetch", () => ({ fetch: jest.fn() }));
import { loremflickrUrl, parseImgUrl } from "../src/genos/tools/images";
import { executeTool, formatWebResults } from "../src/genos/tools/search";
+import {
+ definitionsForProviders,
+ enabledToolDefinitions,
+ executeTool as executeRegistryTool,
+} from "../src/genos/tools";
+import { firecrawlKey } from "../src/genos/providers/firecrawl/key";
describe("semantic image queries (/api/img)", () => {
it("parses the prompt's canonical form", () => {
@@ -68,3 +74,73 @@ describe("web_search tool", () => {
expect(formatWebResults("test", [])).toContain("none found");
});
});
+
+describe("provider-scoped tool registry", () => {
+ const names = (exa: boolean, firecrawl: boolean) =>
+ definitionsForProviders({ exa, firecrawl }).map((tool) => tool.function.name);
+
+ it("composes no keys, Exa only, Firecrawl only, and both providers", () => {
+ expect(names(false, false)).toEqual([]);
+ expect(names(true, false)).toEqual(["web_search"]);
+ expect(names(false, true)).toEqual([
+ "firecrawl_search",
+ "firecrawl_scrape",
+ "firecrawl_agent",
+ ]);
+ expect(names(true, true)).toEqual([
+ "web_search",
+ "firecrawl_search",
+ "firecrawl_scrape",
+ "firecrawl_agent",
+ ]);
+ });
+
+ it("never exposes maxCredits or arbitrary schema as model-controlled Agent arguments", () => {
+ const agent = definitionsForProviders({ exa: false, firecrawl: true }).find(
+ (tool) => tool.function.name === "firecrawl_agent",
+ );
+ const properties = agent?.function.parameters.properties as Record;
+ expect(properties.maxCredits).toBeUndefined();
+ expect(properties.schema).toBeUndefined();
+ });
+
+ it("does not expose or execute Firecrawl before deterministic budget confirmation", async () => {
+ await firecrawlKey.set("fc-disposable-unit-test-only");
+ expect(enabledToolDefinitions({ workflowId: "firecrawl-market-research" })).toEqual([]);
+ expect(
+ enabledToolDefinitions({
+ workflowId: "firecrawl-market-research",
+ firecrawlConfirmed: true,
+ }).map((tool) => tool.function.name),
+ ).toEqual(["firecrawl_search", "firecrawl_scrape", "firecrawl_agent"]);
+ expect(
+ enabledToolDefinitions({
+ workflowId: "firecrawl-market-research",
+ firecrawlConfirmed: true,
+ firecrawlOperation: "agent",
+ }).map((tool) => tool.function.name),
+ ).toEqual(["firecrawl_agent"]);
+ await expect(
+ executeRegistryTool(
+ "firecrawl_search",
+ { query: "must not run" },
+ undefined,
+ undefined,
+ { workflowId: "firecrawl-market-research" },
+ ),
+ ).resolves.toContain("explicit Firecrawl setup and budget confirmation");
+ await expect(
+ executeRegistryTool(
+ "firecrawl_search",
+ { query: "wrong operation" },
+ undefined,
+ undefined,
+ {
+ workflowId: "firecrawl-market-research",
+ firecrawlConfirmed: true,
+ firecrawlOperation: "agent",
+ },
+ ),
+ ).resolves.toContain("confirmed the agent operation");
+ });
+});
diff --git a/__tests__/typography.test.tsx b/__tests__/typography.test.tsx
new file mode 100644
index 0000000..ea12237
--- /dev/null
+++ b/__tests__/typography.test.tsx
@@ -0,0 +1,106 @@
+import React from "react";
+import {
+ Platform,
+ StyleSheet,
+ Text as NativeText,
+ TextInput as NativeTextInput,
+ type TextStyle,
+} from "react-native";
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import {
+ Text,
+ TextInput,
+ TypographyProvider,
+} from "../src/genos/typography";
+
+function renderText(style?: TextStyle | TextStyle[], loaded = true) {
+ let tree!: ReactTestRenderer;
+ act(() => {
+ tree = create(
+
+ Typography
+ ,
+ );
+ });
+ return tree;
+}
+
+function renderedStyle(
+ tree: ReactTestRenderer,
+ type: typeof NativeText | typeof NativeTextInput,
+) {
+ return StyleSheet.flatten(tree.root.findByType(type).props.style) as TextStyle;
+}
+
+describe("global typography wrappers", () => {
+ it("uses the regular Inter family when no weight is requested", () => {
+ const tree = renderText();
+ expect(renderedStyle(tree, NativeText)).toMatchObject({
+ fontFamily: "Inter_400Regular",
+ fontWeight: "normal",
+ });
+ act(() => tree.unmount());
+ });
+
+ it.each([
+ ["300", "Inter_300Light"],
+ ["500", "Inter_500Medium"],
+ ["600", "Inter_600SemiBold"],
+ ["700", "Inter_700Bold"],
+ ["800", "Inter_800ExtraBold"],
+ ] as const)("maps weight %s to %s", (fontWeight, fontFamily) => {
+ const tree = renderText({ fontWeight });
+ expect(renderedStyle(tree, NativeText)).toMatchObject({
+ fontFamily,
+ fontWeight: "normal",
+ });
+ act(() => tree.unmount());
+ });
+
+ it("flattens style arrays before selecting the font family", () => {
+ const tree = renderText([{ fontWeight: "300" }, { fontWeight: "700" }]);
+ expect(renderedStyle(tree, NativeText)).toMatchObject({
+ fontFamily: "Inter_700Bold",
+ fontWeight: "normal",
+ });
+ act(() => tree.unmount());
+ });
+
+ it("preserves an explicit font family", () => {
+ const tree = renderText({ fontFamily: "CustomFamily", fontWeight: "700" });
+ expect(renderedStyle(tree, NativeText)).toMatchObject({
+ fontFamily: "CustomFamily",
+ fontWeight: "normal",
+ });
+ act(() => tree.unmount());
+ });
+
+ it("uses the platform system family before Inter is loaded", () => {
+ const tree = renderText({ fontWeight: "700" }, false);
+ expect(renderedStyle(tree, NativeText)).toMatchObject({
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "sans-serif",
+ default: "system-ui",
+ }),
+ fontWeight: "normal",
+ });
+ act(() => tree.unmount());
+ });
+
+ it("applies the same family and native weight reset to text inputs", () => {
+ let tree!: ReactTestRenderer;
+ act(() => {
+ tree = create(
+
+
+ ,
+ );
+ });
+ expect(renderedStyle(tree, NativeTextInput)).toMatchObject({
+ fontFamily: "Inter_600SemiBold",
+ fontWeight: "normal",
+ });
+ act(() => tree.unmount());
+ });
+});
diff --git a/app.json b/app.json
index 3686a72..c278df1 100644
--- a/app.json
+++ b/app.json
@@ -22,7 +22,8 @@
"favicon": "./assets/favicon.png"
},
"plugins": [
- "expo-secure-store"
+ "expo-secure-store",
+ "expo-font"
]
}
}
diff --git a/package-lock.json b/package-lock.json
index 55ce88a..b4af91b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,10 +10,12 @@
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+ "@expo-google-fonts/inter": "^0.4.2",
"@expo/metro-runtime": "~6.1.2",
"@openuidev/react-lang": "0.1.5",
"@react-native-community/slider": "5.0.1",
"expo": "^54.0.0",
+ "expo-font": "~14.0.12",
"expo-linear-gradient": "~15.0.8",
"expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9",
@@ -1548,6 +1550,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@expo-google-fonts/inter": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@expo-google-fonts/inter/-/inter-0.4.2.tgz",
+ "integrity": "sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ==",
+ "license": "MIT AND OFL-1.1"
+ },
"node_modules/@expo/code-signing-certificates": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
@@ -5708,6 +5716,20 @@
}
}
},
+ "node_modules/expo-font": {
+ "version": "14.0.12",
+ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz",
+ "integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fontfaceobserver": "^2.1.0"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-linear-gradient": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz",
@@ -6184,20 +6206,6 @@
"react-native": "*"
}
},
- "node_modules/expo/node_modules/expo-font": {
- "version": "14.0.12",
- "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz",
- "integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==",
- "license": "MIT",
- "dependencies": {
- "fontfaceobserver": "^2.1.0"
- },
- "peerDependencies": {
- "expo": "*",
- "react": "*",
- "react-native": "*"
- }
- },
"node_modules/expo/node_modules/expo-keep-awake": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
@@ -9548,9 +9556,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9571,9 +9576,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9594,9 +9596,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9617,9 +9616,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
diff --git a/package.json b/package.json
index 42ce9aa..6f9838e 100644
--- a/package.json
+++ b/package.json
@@ -3,10 +3,12 @@
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
+ "@expo-google-fonts/inter": "^0.4.2",
"@expo/metro-runtime": "~6.1.2",
"@openuidev/react-lang": "0.1.5",
"@react-native-community/slider": "5.0.1",
"expo": "^54.0.0",
+ "expo-font": "~14.0.12",
"expo-linear-gradient": "~15.0.8",
"expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9",
diff --git a/plans/001-linear-typography.md b/plans/001-linear-typography.md
new file mode 100644
index 0000000..58d9524
--- /dev/null
+++ b/plans/001-linear-typography.md
@@ -0,0 +1,192 @@
+# Plan 001: Finish and verify Linear-style global typography
+
+> **Executor instructions**: Follow this plan step by step. Run every
+> verification command and confirm the expected result before moving on. If a
+> STOP condition occurs, stop and report it rather than improvising. When done,
+> update this plan's row in `plans/README.md`.
+>
+> **Drift check (run first)**:
+> `git diff --stat 9867af9 -- App.tsx package.json package-lock.json app.json src/genos/typography.tsx src/genos/GenOS.tsx src/genos/shell src/genos/ui`
+> The plan was written against uncommitted typography changes, so a non-empty
+> diff is expected. Compare the current-state excerpts below before editing. If
+> they no longer match in substance, stop and report drift.
+
+## Status
+
+- **Priority**: P1
+- **Effort**: S (hours)
+- **Risk**: LOW
+- **Depends on**: none
+- **Category**: direction
+- **Planned at**: commit `9867af9`, 2026-08-16, with uncommitted UI changes
+
+## Why this matters
+
+The requested Linear-style font rollout is already mostly present in the working
+tree, but it lacks direct typography tests and a documented definition of
+"Linear font." Linear's current public CSS uses Inter Variable as its regular
+UI family. AppLess now loads static Inter weight files through Expo, which is the
+portable React Native equivalent, but the change should not be considered done
+until every native `Text`/`TextInput` path and weight fallback is verified.
+
+## Current state
+
+- `App.tsx:2-25` imports Inter 300 through 800 from
+ `@expo-google-fonts/inter` and loads them with `useFonts`.
+- `App.tsx:31-39` waits for loading or an error, then supplies the loaded state
+ through `TypographyProvider`.
+- `src/genos/typography.tsx:12-66` maps numeric weights to named Inter font
+ families and wraps React Native `Text` and `TextInput`.
+- `src/genos/typography.tsx:69-131` defines a `linearType` scale.
+- All current JSX text imports have been moved to `src/genos/typography.tsx`;
+ `react-native-svg` text remains separate by design.
+- `package.json` already contains `@expo-google-fonts/inter` and `expo-font`, and
+ `app.json` already lists the `expo-font` plugin.
+- Current verification: `npm run typecheck` passes and
+ `npm test -- --runInBand --no-watchman` passes 22 tests.
+
+Reference evidence:
+
+- Linear currently declares `--font-regular: "Inter Variable", ...` and uses
+ weights around 400/510/590/680.
+- Linear also declares Berkeley Mono for monospace, but this app has no
+ monospace semantic role. Do not add it globally.
+
+## Commands you will need
+
+| Purpose | Command | Expected on success |
+|---------|---------|---------------------|
+| Typecheck | `npm run typecheck` | exit 0, no TypeScript errors |
+| Tests | `npm test -- --runInBand --no-watchman` | all suites and tests pass |
+| Raw text audit | `rg -n '(^|[,{ ])Text(Input)?([, }]| as )' src App.tsx -g '*.tsx'` | only typography wrapper aliases or intentional SVG text remain |
+
+## Scope
+
+**In scope**:
+
+- `App.tsx`
+- `app.json`
+- `package.json`
+- `package-lock.json`
+- `src/genos/typography.tsx`
+- Existing `.tsx` files already changed to import the typography wrappers
+- `__tests__/typography.test.tsx` (create)
+- `README.md` only for a short typography note if needed
+
+**Out of scope**:
+
+- Changing colors, spacing, layout, icons, or wallpaper
+- Adding Berkeley Mono to regular UI text
+- Downloading fonts from Linear's CDN
+- Replacing SVG chart text; SVG text does not accept React Native `Text`
+- Reworking the generated OpenUI component contract
+
+## Git workflow
+
+- Branch: `advisor/001-linear-typography`
+- Existing commits use imperative sentence case, for example
+ `Add anonymous run analytics (PostHog) (#4)`; use the same style.
+- Do not push or open a PR unless instructed.
+- Preserve all pre-existing uncommitted changes; this plan finishes them rather
+ than recreating or reverting them.
+
+## Steps
+
+### Step 1: Lock the semantic font behavior with tests
+
+Create `__tests__/typography.test.tsx`. Render `Text` and `TextInput` inside
+`TypographyProvider` with `react-test-renderer` and assert:
+
+- loaded + no weight selects `Inter_400Regular`;
+- weights 300, 500, 600, 700, and 800 select the matching named family;
+- a style array is flattened correctly;
+- an explicit `fontFamily` is preserved;
+- `loaded={false}` selects the platform system fallback;
+- the wrapper writes `fontWeight: "normal"` after selecting the correct file so
+ React Native does not synthetically re-weight a named font file.
+
+Do not export private implementation details solely for testing unless rendering
+cannot observe the final style.
+
+**Verify**:
+`npm test -- --runInBand --no-watchman __tests__/typography.test.tsx` -> the new
+suite passes.
+
+### Step 2: Finish the global import audit
+
+Audit every `.tsx` file under `src/` and `App.tsx`. Ordinary text must import
+`Text` and `TextInput` from `src/genos/typography.tsx` (using the correct relative
+path). The only permitted exceptions are:
+
+- `NativeText` and `NativeTextInput` aliases inside `typography.tsx`;
+- `SvgText` from `react-native-svg` in chart rendering.
+
+Keep existing size and weight values unless a value is moved to an already
+defined `linearType` token without changing rendered hierarchy.
+
+**Verify**:
+`rg -n 'Text(Input)?[^\n]*from "react-native"' src App.tsx -g '*.tsx'` -> no
+ordinary UI imports are returned.
+
+### Step 3: Verify loading and fallback behavior
+
+Confirm `App.tsx` never displays a partially loaded mixed-font UI. Keep the
+current behavior of returning `null` while fonts are loading, but allow the app
+to render with system fonts when `useFonts` reports an error. Add a focused test
+only if this requires logic beyond the existing condition.
+
+Check `app.json` has exactly one `expo-font` plugin entry and that package and
+lockfile versions agree.
+
+**Verify**: `npm run typecheck` -> exit 0.
+
+### Step 4: Run cross-renderer regression tests
+
+Run the complete render suites because the wrapper touches both Cupertino and
+Material component libraries.
+
+**Verify**: `npm test -- --runInBand --no-watchman` -> all suites pass, including
+the new typography suite.
+
+## Test plan
+
+- New `__tests__/typography.test.tsx` coverage as described in Step 1.
+- Existing `__tests__/render.test.tsx` verifies Cupertino components still
+ render through the OpenUI pipeline.
+- Existing `__tests__/render-material.test.tsx` verifies Material components.
+- Manual device check after automated tests: one iOS and one Android launch,
+ checking home, key gate, generated list, form input, chart labels, switcher,
+ and error/retry state for missing glyphs or synthetic bold.
+
+## Done criteria
+
+- [ ] `npm run typecheck` exits 0.
+- [ ] `npm test -- --runInBand --no-watchman` exits 0.
+- [ ] Typography tests cover regular, all used weights, explicit family, style
+ arrays, and unloaded fallback.
+- [ ] No ordinary UI component imports `Text` or `TextInput` directly from
+ `react-native`.
+- [ ] Inter is used for global UI copy on iOS, Android, and web.
+- [ ] No unrelated visual values were changed.
+- [ ] `plans/README.md` status is updated.
+
+## STOP conditions
+
+Stop and report if:
+
+- the current Inter wrapper or `App.tsx` loading code no longer matches the
+ current-state description;
+- a platform cannot resolve the named Inter families loaded by `useFonts`;
+- completing the audit requires changing generated OpenUI source or SVG text;
+- tests still fail twice when run with `--no-watchman`.
+
+## Maintenance notes
+
+- If AppLess later adds code blocks or structured raw data, introduce a separate
+ monospace token; Berkeley Mono requires its own redistribution/license review.
+- Reviewers should scrutinize `fontWeight` ordering in style arrays. The wrapper
+ intentionally converts requested weight to a concrete family, then resets
+ native weight to normal.
+- React Native variable-font behavior differs by platform, so do not replace
+ the named files with a single variable font without device-level proof.
+
diff --git a/plans/002-provider-favicons.md b/plans/002-provider-favicons.md
new file mode 100644
index 0000000..92870b5
--- /dev/null
+++ b/plans/002-provider-favicons.md
@@ -0,0 +1,199 @@
+# Plan 002: Add Raycast-compatible provider favicons
+
+> **Executor instructions**: Follow this plan step by step and run every
+> verification gate. Stop on any listed STOP condition. Update the matching row
+> in `plans/README.md` when done.
+>
+> **Drift check (run first)**:
+> `git diff --stat 9867af9 -- src/genos/apps.ts src/genos/GenOS.tsx src/genos/shell/HomeScreen.tsx src/genos/shell/Switcher.tsx`
+> Compare the excerpts below against the working tree because these paths have
+> pre-existing uncommitted UI changes.
+
+## Status
+
+- **Priority**: P1
+- **Effort**: S (hours)
+- **Risk**: LOW
+- **Depends on**: none
+- **Category**: direction
+- **Planned at**: commit `9867af9`, 2026-08-16, with uncommitted UI changes
+
+## Why this matters
+
+Provider-backed commands need recognizable, consistent branding in the command
+menu, minimized home tiles, and app switcher. Raycast's `getFavicon` establishes
+the desired behavior, but the helper cannot be imported into Expo because it
+returns `@raycast/api` image types. A tiny app-native resolver can use the same
+Raycast favicon service and provide deterministic fallbacks without adding an
+incompatible runtime.
+
+## Current state
+
+- `src/genos/apps.ts:1-9` defines `AppDef` with only `id`, `name`, `emoji`, tile
+ colors, and a generation request.
+- `src/genos/GenOS.tsx` copies only `name`, `emoji`, and `tile` into `AppMeta` and
+ `RunningApp`.
+- `src/genos/shell/HomeScreen.tsx:127-190` maps emoji and keywords to Phosphor
+ icons; it has no provider concept.
+- `src/genos/shell/Switcher.tsx:10-15` defines the same minimal `RunningApp`
+ metadata and renders the emoji in a gradient.
+- Raycast's open-source implementation normalizes a host and constructs
+ `https://api.ray.so/favicon?url=&size=`, defaulting to 64px and
+ a link-icon fallback.
+
+## Commands you will need
+
+| Purpose | Command | Expected on success |
+|---------|---------|---------------------|
+| Typecheck | `npm run typecheck` | exit 0 |
+| Tests | `npm test -- --runInBand --no-watchman` | all pass |
+| Dependency audit | `npm ls @raycast/api @raycast/utils` | both absent; a nonzero npm result is acceptable only because they are not installed |
+
+## Suggested executor toolkit
+
+- Reference Raycast's official docs:
+ `https://developers.raycast.com/utilities/icons/getfavicon`.
+- Reference the open-source implementation:
+ `https://github.com/raycast/utils/blob/main/src/icon/favicon.ts`.
+
+## Scope
+
+**In scope**:
+
+- `src/genos/providers.ts` (create)
+- `src/genos/ui/ProviderIcon.tsx` (create)
+- `src/genos/apps.ts`
+- `src/genos/GenOS.tsx`
+- `src/genos/shell/HomeScreen.tsx`
+- `src/genos/shell/Switcher.tsx`
+- `__tests__/providers.test.tsx` (create)
+
+**Out of scope**:
+
+- Installing `@raycast/api` or `@raycast/utils`
+- Scraping arbitrary pages to discover ``
+- Changing built-in app icons that already use Phosphor glyphs
+- Storing provider credentials
+- Adding slash-command UI or Firecrawl network calls (Plans 003 and 004)
+
+## Git workflow
+
+- Branch: `advisor/002-provider-favicons`
+- Match the repository's imperative sentence-case commit messages.
+- Do not push or open a PR unless instructed.
+- Preserve the current uncommitted typography changes.
+
+## Steps
+
+### Step 1: Create a typed provider registry
+
+Create `src/genos/providers.ts` with:
+
+```ts
+export interface ProviderDef {
+ id: string;
+ name: string;
+ domain: string;
+ homepage: string;
+ tile: [string, string];
+ fallbackGlyph: string;
+}
+```
+
+Add a `firecrawl` entry for `firecrawl.dev`. Export a lookup that returns
+`undefined` for unknown IDs. Keep provider metadata separate from slash command
+metadata so more commands can share one provider.
+
+Add a pure `raycastFaviconUrl(domainOrUrl, size = 64)` function that:
+
+- accepts a bare host or HTTPS URL;
+- normalizes with `new URL`, adding `https://` when missing;
+- uses only `.hostname` in the request;
+- clamps size to a small safe range such as 16-256;
+- returns `https://api.ray.so/favicon?url=${encodeURIComponent(hostname)}&size=${size}`;
+- returns `null` for malformed input instead of throwing.
+
+**Verify**: `npm run typecheck` -> exit 0.
+
+### Step 2: Add a React Native provider icon with fallback
+
+Create `src/genos/ui/ProviderIcon.tsx`. It should accept `providerId`, `size`, and
+an optional corner radius. Resolve the provider, render a React Native `Image`
+for the Raycast URL, and switch to a local Phosphor `Globe`/`Link` style glyph on
+load error or invalid metadata. Reset the error state when `providerId` changes.
+Give the image an accessibility label such as `Firecrawl provider`.
+
+Do not block app launch on icon fetches. Do not render a blank square while the
+favicon is unavailable.
+
+**Verify**: focused tests can render success metadata and force the error path
+without network access.
+
+### Step 3: Carry provider identity through session metadata
+
+Add optional `providerId?: string` to `AppDef`, the internal `AppMeta`, and
+`RunningApp`. Ensure `launch`, deep-link metadata, the `runningApps` memo, home
+resume state, and switcher state preserve it. Existing built-in apps must behave
+unchanged when no provider is present.
+
+Use `ProviderIcon` in:
+
+- the home/minimized tile when `providerId` exists;
+- the switcher header icon and empty preview when `providerId` exists.
+
+Keep the existing Phosphor/emoji fallback for all non-provider apps.
+
+**Verify**: `npm run typecheck` -> exit 0.
+
+### Step 4: Add resolver and rendering tests
+
+Create `__tests__/providers.test.tsx` covering:
+
+- bare domain and full URL normalization;
+- path/query removal;
+- size default and clamp;
+- malformed input returns `null`;
+- known and unknown provider lookup;
+- provider icon fallback after an image error;
+- built-in app metadata remains provider-free.
+
+**Verify**: `npm test -- --runInBand --no-watchman __tests__/providers.test.tsx`
+-> the new suite passes.
+
+## Test plan
+
+- Pure URL and registry tests in `__tests__/providers.test.tsx`.
+- Component fallback test using `react-test-renderer`; invoke the `Image` error
+ handler directly rather than making a network request.
+- Existing full render suites must continue to pass.
+- Manual offline check: Firecrawl session shows a local fallback icon and all
+ navigation remains usable.
+
+## Done criteria
+
+- [ ] No Raycast package is installed.
+- [ ] The resolver matches Raycast's `api.ray.so/favicon` hostname/size shape.
+- [ ] Invalid URLs and image errors produce a visible local fallback.
+- [ ] Provider ID survives launch, minimize, resume, switcher, and close flows.
+- [ ] Built-in app icons are visually unchanged.
+- [ ] `npm run typecheck` and the complete no-Watchman test suite pass.
+- [ ] `plans/README.md` status is updated.
+
+## STOP conditions
+
+Stop and report if:
+
+- Raycast changes the official provider URL or prohibits this use;
+- `api.ray.so` cannot be loaded by React Native on either iOS or Android;
+- provider identity would require persisting arbitrary remote image URLs instead
+ of a trusted registry ID;
+- required edits expand into generated OpenUI component files.
+
+## Maintenance notes
+
+- Centralize the service URL in the resolver so a future provider/fallback can
+ be swapped without touching UI components.
+- Reviewers should check URL parsing, encoding, image error loops, offline
+ behavior, and accessibility labels.
+- Treat favicons as decoration, never as proof of provider identity or auth.
+
diff --git a/plans/003-firecrawl-runtime.md b/plans/003-firecrawl-runtime.md
new file mode 100644
index 0000000..ae3450f
--- /dev/null
+++ b/plans/003-firecrawl-runtime.md
@@ -0,0 +1,338 @@
+# Plan 003: Add the Firecrawl provider runtime
+
+> **Executor instructions**: Follow the steps in order, run each verification,
+> and stop on any STOP condition. This plan adds real credit-bearing network
+> operations; do not substitute guessed endpoint behavior. Update the row in
+> `plans/README.md` when done.
+>
+> **Drift check (run first)**:
+> `git diff --stat 9867af9 -- src/config.ts src/genos/stream.ts src/genos/store.ts src/genos/tools __tests__/tools.test.ts`
+> Compare the current-state excerpts before editing.
+
+## Status
+
+- **Priority**: P1
+- **Effort**: L (multi-day)
+- **Risk**: HIGH
+- **Depends on**: none
+- **Category**: direction
+- **Planned at**: commit `9867af9`, 2026-08-16, with uncommitted UI changes
+
+## Why this matters
+
+The requested Firecrawl workflows cannot be delivered by adding labels alone.
+AppLess currently exposes one synchronous Exa `web_search` tool to Cerebras.
+Directory extraction, lead generation, lead research, and deep research need a
+real Firecrawl execution layer with provider-scoped instructions, structured
+outputs, async Agent polling, cancellation, explicit credit caps, and BYOK key
+handling. This is the highest-risk plan because it spends external credits and
+can run for minutes.
+
+## Current state
+
+- `src/config.ts:14-16` supports optional public Exa and Unsplash keys, while the
+ Cerebras key uses a device-backed store.
+- `src/genos/tools/search.ts:25-66` hard-codes one OpenAI tool definition and one
+ name switch for `web_search`.
+- `src/genos/stream.ts:14` imports the search module directly.
+- `src/genos/stream.ts:39-40` allows at most three tool rounds.
+- `src/genos/stream.ts:249-287` awaits tool results inline before asking
+ Cerebras to compose the screen.
+- `src/genos/store.ts:8-33` has no workflow/provider context on a screen.
+- `src/genos/store.ts:211-220` correctly refuses every tool call during
+ speculative prefetch; preserve this credit-safety rule.
+- Firecrawl's current API uses `POST https://api.firecrawl.dev/v2/search`,
+ `POST /v2/scrape`, `POST /v2/agent`, and `GET /v2/agent/` for status.
+ Agent states include `processing`, `completed`, `failed`, and `cancelled`.
+
+## Commands you will need
+
+| Purpose | Command | Expected on success |
+|---------|---------|---------------------|
+| Typecheck | `npm run typecheck` | exit 0 |
+| Unit tests | `npm test -- --runInBand --no-watchman __tests__/tools.test.ts __tests__/store.test.ts` | all pass |
+| Full tests | `npm test -- --runInBand --no-watchman` | all pass |
+
+## Suggested executor toolkit
+
+- Read the current official Firecrawl docs before coding:
+ `https://docs.firecrawl.dev/introduction` and
+ `https://docs.firecrawl.dev/features/agent`.
+- Use the Node/REST examples as the protocol source of truth; do not add the
+ Node SDK unless it is verified to work in Expo/Hermes and materially reduces
+ code. Direct `expo/fetch` matches the repository's current convention.
+
+## Scope
+
+**In scope**:
+
+- `src/config.ts`
+- `src/genos/providers/firecrawl/key.ts` (create; path may be
+ `src/genos/tools/firecrawl/key.ts` if provider folders are not adopted)
+- `src/genos/shell/ProviderKeyGate.tsx` (create)
+- `src/genos/tools/index.ts` (create)
+- `src/genos/tools/search.ts`
+- `src/genos/tools/firecrawl.ts` (create)
+- `src/genos/stream.ts`
+- `src/genos/store.ts`
+- `src/genos/workflows.ts` (create compact workflow contracts)
+- `__tests__/tools.test.ts`
+- `__tests__/store.test.ts`
+- `__tests__/firecrawl.test.ts` (create)
+- `README.md` and `.env` documentation, never a real key
+
+**Out of scope**:
+
+- Slash-command menu and command discovery (Plan 004)
+- Provider favicons (Plan 002)
+- Bundling local Codex `SKILL.md` files
+- Automatically submitting CRM records or sending outreach
+- Bypassing login walls, CAPTCHAs, robots restrictions, or paywalls
+- Uploading CSV files in the first release
+- A shared production Firecrawl key in an Expo public environment variable
+
+## Git workflow
+
+- Branch: `advisor/003-firecrawl-runtime`
+- Match imperative sentence-case commit messages.
+- Prefer logical commits: tool registry, Firecrawl client/key store, workflow
+ context, then tests/docs.
+- Do not push or open a PR unless instructed.
+
+## Steps
+
+### Step 1: Generalize the tool registry without changing Exa behavior
+
+Create `src/genos/tools/index.ts` as the single source for:
+
+- enabled OpenAI-format tool definitions;
+- enabled prompt sections;
+- `executeTool(name, args, signal, progress?)` dispatch;
+- provider availability.
+
+Move no Exa network behavior unless necessary. `web_search` must keep its exact
+name, request shape, result formatting, error-as-string behavior, and existing
+tests. Update `stream.ts` to import only from the new registry.
+
+Avoid one global `toolsAvailable()` boolean that exposes disabled provider tools.
+Build definitions from the keys/providers actually available for the active
+screen.
+
+**Verify**: existing tool tests and `npm run typecheck` pass before Firecrawl is
+added.
+
+### Step 2: Add a Firecrawl BYOK key store
+
+Model the existing Cerebras key store but keep provider status separate. Store a
+user-entered key in SecureStore on iOS/Android and localStorage on web under a
+Firecrawl-specific key. Support `loading`, `missing`, `present`, and `rejected`.
+Clear only the exact key rejected by a 401/403 response.
+
+An optional development-only environment key may be supported for local builds,
+but document that any `EXPO_PUBLIC_*` secret is bundled into the client and must
+not be used as a shared production credential.
+
+Do not make Firecrawl mandatory for ordinary AppLess use. Missing Firecrawl
+credentials disable Firecrawl tools but leave Cerebras and Exa flows intact.
+
+Add a reusable, non-global `ProviderKeyGate` for entering/replacing the key. It
+must identify Firecrawl, explain on-device storage and credit usage, link to the
+official key page, and be shown only when a Firecrawl action needs it. It must
+never replace the existing Cerebras startup gate.
+
+**Verify**: unit tests mock SecureStore and cover hydrate, set, reject-current,
+do-not-reject-replaced-key, and key-gate dismissal without affecting Cerebras.
+
+### Step 3: Implement Firecrawl primitives
+
+In `src/genos/tools/firecrawl.ts`, implement small typed clients using
+`expo/fetch`:
+
+- `firecrawl_search`: `POST /v2/search` with query, a bounded limit, and optional
+ scrape formats;
+- `firecrawl_scrape`: `POST /v2/scrape` for one validated HTTP(S) URL and bounded
+ markdown/JSON output;
+- `firecrawl_agent`: `POST /v2/agent` with prompt, optional trusted schema/model/
+ URLs, and an explicit `maxCredits` supplied by the workflow contract;
+- `getFirecrawlAgentStatus`: `GET /v2/agent/`.
+
+For every endpoint:
+
+- attach `Authorization: Bearer ` and JSON headers;
+- validate arguments before network access;
+- allow only `http:` and `https:` URLs;
+- cap result counts and text passed back to Cerebras to stay within context;
+- never log the key, authorization headers, or raw sensitive result bodies;
+- mark the current key rejected on 401/403;
+- map 429 and credit-limit errors to actionable error text;
+- preserve source URLs and explicit missing fields;
+- return error strings to the model rather than inventing data.
+
+Do not expose arbitrary JSON schema supplied verbatim by the model. Choose a
+schema from a trusted `workflowId` registry or validate it against a strict size
+and depth budget.
+
+**Verify**: mocked fetch tests assert endpoint, method, sanitized body, auth
+presence without snapshotting the secret, and response/error handling.
+
+### Step 4: Make Agent execution asynchronous, cancellable, and bounded
+
+Implement polling as a dedicated helper, not an unbounded loop inside
+`executeTool`:
+
+- poll only while status is `processing`;
+- use a 2-5 second delay with a documented maximum elapsed time based on the
+ workflow depth;
+- stop immediately when the screen's AbortSignal fires;
+- call the documented Agent cancellation endpoint on user cancellation if the
+ current API supports it; otherwise stop local polling and document that the
+ remote job may continue consuming credits;
+- return `completed` data plus `creditsUsed` and source metadata;
+- map `failed` and `cancelled` distinctly;
+- time out with the job ID retained in a safe error so a later status check can
+ recover it;
+- never silently retry a new Agent job after a timeout.
+
+Expose progress states such as `starting`, `processing`, and elapsed time to the
+screen store. Replace the current boolean-only `searching` state with a backwards
+compatible tool-progress shape or add a new optional field. The shell must still
+be usable while the job runs.
+
+**Verify**: fake-timer tests cover processing -> completed, processing -> failed,
+timeout, abort, and no duplicate POST on retry/status recovery.
+
+### Step 5: Persist workflow context through screen navigation
+
+Add `workflowId?: string` to `AppDef`, `Screen`, and `LaunchInput`. `openApp`
+copies it to the first screen; `resolveAction`, prefetch children, retry, and deep
+navigation inherit it from the parent. Do not paste the full workflow prompt into
+every user request.
+
+Build a compact trusted workflow contract from `workflowId` and append it to the
+system prompt for that screen. The runtime is not complete until it has contracts
+for the full catalog below. Related workflows may share primitives and output
+helpers, but each ID needs its own input requirements, collection policy, result
+shape, and tests:
+
+- `firecrawl-company-directories`: visible fields, dedupe, pagination progress,
+ and blanks for unavailable data;
+- `firecrawl-competitive-intel`: current pricing/features/changelog evidence,
+ timestamped comparisons, and explicit conflicting or missing claims;
+- `firecrawl-dashboard-reporting`: authorized dashboard/session boundaries,
+ metric definitions, reporting period, and no credential capture in output;
+- `firecrawl-deep-research`: explicit quick/thorough/exhaustive depth, citations,
+ synthesis, risks, and open questions;
+- `firecrawl-demo-walkthrough`: bounded product flow, observed UX evidence, and
+ no state-changing action without explicit permission;
+- `firecrawl-knowledge-base` and `firecrawl-knowledge-ingest`: scoped sources,
+ crawl boundaries, provenance, dedupe, update timestamp, and login limitations;
+- `firecrawl-lead-gen`: legitimately accessible fields only, dedupe, data gaps,
+ and no access-control bypass;
+- `firecrawl-lead-research`: concise sourced brief with facts separated from
+ inferred pain points;
+- `firecrawl-market-research`: dated metrics, primary-source preference,
+ methodology, and uncertainty;
+- `firecrawl-qa`: bounded target and charter, reproducible steps, evidence, and
+ no destructive submissions;
+- `firecrawl-research-papers`: paper metadata, primary PDF/source links,
+ methodology, results, limitations, and no invented citations;
+- `firecrawl-seo-audit`: crawl boundary, metadata/indexability evidence,
+ prioritized findings, and page samples;
+- `firecrawl-shop`: constraints, current price/availability evidence,
+ comparisons, and no purchase action;
+- `firecrawl-website-design-clone`: observed tokens/components, asset provenance,
+ and a DESIGN.md-shaped result without copying protected content wholesale;
+- `firecrawl-workflows`: a safe chooser that routes to one concrete contract
+ rather than acting as an unbounded generic Agent;
+- base `firecrawl`: an explicit search, scrape, or Agent choice, with Agent
+ requiring cost/depth confirmation.
+
+All workflow screens must preserve source URLs and must not fabricate missing
+email, phone, funding, roles, or company facts.
+
+**Verify**: store tests assert workflow inheritance across root, action child,
+form child, prefetch, and retry.
+
+### Step 6: Define cost and depth policy
+
+Every Agent call must receive an explicit `maxCredits`; never rely on the current
+API default of 2,500. Put budget ranges in workflow definitions and require the
+UI/command layer to select one before execution. Recommended initial policy:
+
+- base search/scrape: no Agent call;
+- structured directory, lead, shopping, SEO, QA, knowledge, dashboard, and demo
+ workflows: 150 credits by default, with requested row/page limits included in
+ the prompt and schema;
+- competitive and market research: 250 credits by default;
+- deep research: quick = 100, thorough = 300, exhaustive = 750 credits, with
+ increasing timeouts and a separate confirmation for exhaustive;
+- pro model only for an explicit high-accuracy choice, never by default.
+
+Treat these as maximums, not expected spend. Validate them with disposable
+low-credit test runs before production and adjust only in the central policy,
+never in individual UI components.
+
+**Verify**: a unit test fails if any Agent definition can execute with missing or
+non-positive `maxCredits`.
+
+### Step 7: Document and verify the provider runtime
+
+Update README setup text with Firecrawl BYOK behavior, supported primitives,
+credit/cost warning, and limitations. Do not claim the specialized slash commands
+exist until Plan 004 lands.
+
+Run the full test suite and one manual smoke test per endpoint with a disposable
+low-credit key. The manual test must not use real personal lead data.
+
+**Verify**: `npm run typecheck` and
+`npm test -- --runInBand --no-watchman` both exit 0.
+
+## Test plan
+
+- `__tests__/firecrawl.test.ts`: request validation, auth rejection, 429/credit
+ error, content truncation, source retention, polling state machine, abort, and
+ explicit credit cap.
+- `__tests__/tools.test.ts`: registry composition with no keys, Exa only,
+ Firecrawl only, and both providers.
+- `__tests__/store.test.ts`: workflow context inheritance and no speculative
+ credit-bearing calls.
+- Manual low-credit smoke tests: search, scrape one public page, one tiny Agent
+ structured extraction, status polling, and cancel.
+
+## Done criteria
+
+- [ ] Missing Firecrawl credentials do not affect ordinary AppLess behavior.
+- [ ] No shared Firecrawl secret appears in source, public config, logs, tests, or
+ snapshots.
+- [ ] Every Agent run has an explicit timeout, AbortSignal, and `maxCredits`.
+- [ ] Speculative prefetch never executes Firecrawl.
+- [ ] Every Firecrawl catalog ID has a trusted runtime contract and tests.
+- [ ] Workflow context survives every child screen.
+- [ ] Facts retain source URLs; unavailable structured fields stay blank.
+- [ ] `npm run typecheck` and all no-Watchman tests pass.
+- [ ] README documents BYOK, cost, and current limitations.
+- [ ] `plans/README.md` status is updated.
+
+## STOP conditions
+
+Stop and report if:
+
+- Firecrawl's current API differs from the documented v2 endpoint/state model;
+- Firecrawl does not support required cross-origin/native requests and the fix
+ would require introducing a backend;
+- cancellation cannot prevent or bound credit usage sufficiently for product
+ approval;
+- Cerebras rejects the expanded tool schema or cannot handle the tool outputs;
+- real execution requires CAPTCHA/access-control bypass;
+- production would require caps above the central policy without a new explicit
+ product decision.
+
+## Maintenance notes
+
+- Firecrawl Agent was marked Research Preview in the documentation reviewed for
+ this plan; endpoint shapes and pricing can change. Keep protocol code isolated.
+- Reviewers should scrutinize credit caps, duplicate job creation, abort behavior,
+ schema trust boundaries, secret handling, and output-size limits.
+- Long research is fundamentally different from the current sub-second screen
+ generation path. Preserve job IDs so future versions can resume across app
+ restarts without rerunning paid work.
diff --git a/plans/004-firecrawl-slash-commands.md b/plans/004-firecrawl-slash-commands.md
new file mode 100644
index 0000000..beceb59
--- /dev/null
+++ b/plans/004-firecrawl-slash-commands.md
@@ -0,0 +1,301 @@
+# Plan 004: Add the Firecrawl slash-command catalog and menu
+
+> **Executor instructions**: Implement only after Plans 002 and 003 are DONE.
+> Follow each verification gate and stop on any STOP condition. Update the row in
+> `plans/README.md` when done.
+>
+> **Drift check (run first)**:
+> `git diff --stat 9867af9 -- src/genos/apps.ts src/genos/GenOS.tsx src/genos/shell/HomeScreen.tsx src/genos/shell/Switcher.tsx src/genos/providers.ts src/genos/tools src/genos/workflows.ts`
+> Plans 002 and 003 are expected to change these paths. Confirm their done
+> criteria and adapt only names, not architecture.
+
+## Status
+
+- **Priority**: P1
+- **Effort**: M (a day-ish)
+- **Risk**: MED
+- **Depends on**: `plans/002-provider-favicons.md`,
+ `plans/003-firecrawl-runtime.md`
+- **Category**: direction
+- **Planned at**: commit `9867af9`, 2026-08-16, with uncommitted UI changes
+
+## Why this matters
+
+Users need a discoverable way to invoke Firecrawl's base operations and its
+specialized workflows from the existing "Ask for anything" field. A typed command
+catalog and filtered slash menu provide that surface while keeping ordinary
+natural-language routing unchanged. Each command must launch a provider-branded
+session with its own onboarding inputs and workflow contract, not merely prepend
+a decorative command name to a generic prompt.
+
+## Current state
+
+- `src/genos/shell/HomeScreen.tsx:318-349` owns a plain `ask` string and submits
+ it unchanged through `onCommand`.
+- `src/genos/shell/HomeScreen.tsx:438-479` renders the only ask input; there is no
+ command menu or selected-command state.
+- `src/genos/GenOS.tsx:490-536` routes OS intents, known apps, active-app actions,
+ and generic summoned apps. It does not parse a leading slash.
+- `src/genos/apps.ts:114-121` can create a generic summoned app but has no
+ workflow command helper.
+- Plan 002 adds provider metadata and Firecrawl favicon rendering.
+- Plan 003 adds `workflowId`, provider availability, Firecrawl BYOK, and the real
+ execution runtime.
+
+## Commands you will need
+
+| Purpose | Command | Expected on success |
+|---------|---------|---------------------|
+| Typecheck | `npm run typecheck` | exit 0 |
+| Command tests | `npm test -- --runInBand --no-watchman __tests__/commands.test.tsx` | all pass |
+| Full tests | `npm test -- --runInBand --no-watchman` | all pass |
+
+## Scope
+
+**In scope**:
+
+- `src/genos/commands.ts` (create)
+- `src/genos/shell/CommandMenu.tsx` (create)
+- `src/genos/shell/WorkflowSetup.tsx` (create)
+- `src/genos/shell/HomeScreen.tsx`
+- `src/genos/GenOS.tsx`
+- `src/genos/apps.ts`
+- Provider/workflow registries created by Plans 002 and 003
+- `__tests__/commands.test.tsx` (create)
+- `__tests__/store.test.ts` for launched workflow metadata
+- `README.md`
+
+**Out of scope**:
+
+- Reimplementing Firecrawl network clients
+- Importing local `SKILL.md` files at runtime
+- File/CSV upload, download/export, CRM writeback, or outreach sending
+- Executing disabled commands without a Firecrawl key
+- Adding command palettes for unrelated providers
+- Changing ordinary OS intent routing
+
+## Git workflow
+
+- Branch: `advisor/004-firecrawl-slash-commands`
+- Match imperative sentence-case commit messages.
+- Keep catalog/parser, UI, and routing/tests as separable logical commits.
+- Do not push or open a PR unless instructed.
+
+## Steps
+
+### Step 1: Define the command contract and catalog
+
+Create `src/genos/commands.ts` with a typed immutable definition such as:
+
+```ts
+interface SlashCommandDef {
+ id: string; // exact slash name without '/'
+ title: string;
+ description: string;
+ providerId: "firecrawl";
+ workflowId: string;
+ inputHint: string;
+ availability: "enabled" | "needs-key" | "unavailable";
+}
+```
+
+Seed the catalog with the complete currently installed Firecrawl workflow set:
+
+- `/firecrawl`
+- `/firecrawl-company-directories`
+- `/firecrawl-competitive-intel`
+- `/firecrawl-dashboard-reporting`
+- `/firecrawl-deep-research`
+- `/firecrawl-demo-walkthrough`
+- `/firecrawl-knowledge-base`
+- `/firecrawl-knowledge-ingest`
+- `/firecrawl-lead-gen`
+- `/firecrawl-lead-research`
+- `/firecrawl-market-research`
+- `/firecrawl-qa`
+- `/firecrawl-research-papers`
+- `/firecrawl-seo-audit`
+- `/firecrawl-shop`
+- `/firecrawl-website-design-clone`
+- `/firecrawl-workflows`
+
+Only mark a command `enabled` when Plan 003 has a corresponding trusted workflow
+contract and backend path. Completion requires every command above to be
+runnable; `unavailable` exists for a runtime/config failure, not as a placeholder
+for unfinished implementation. Future workflows should remain a data change plus
+contract/tests, not another HomeScreen rewrite.
+
+`/firecrawl` is the base router: known URL -> scrape; search-like query -> search;
+complex autonomous structured task -> offer Agent with an explicit cost/depth
+choice. It must not guess a costly Agent mode silently.
+
+**Verify**: catalog unit tests assert unique IDs, valid provider/workflow IDs,
+stable ordering, and no enabled command without runtime support.
+
+### Step 2: Add pure parsing and filtering
+
+Export pure helpers:
+
+- `parseSlashCommand(text)`: recognize a command only at the beginning, split
+ exact command ID from the remaining argument, and report unknown commands;
+- `filterSlashCommands(text)`: while the input begins with `/`, match ID, title,
+ and keywords case-insensitively;
+- `commandToApp(command, argument)`: create an `AppDef` with a stable unique app
+ ID, Firecrawl provider ID, workflow ID, a human-readable session name, and a
+ request that includes user arguments but not secrets.
+
+Do not use prefix matching for execution: `/firecrawl-lead` must not execute
+`/firecrawl-lead-gen`. Preserve spaces and URLs in the argument. Cap user input
+length consistently with the Firecrawl API.
+
+**Verify**: tests cover exact match, partial filtering, unknown command, empty
+argument, URL with query string, mixed case, leading whitespace policy, and
+command injection-like text after the argument.
+
+### Step 3: Build the inline command menu
+
+Create `src/genos/shell/CommandMenu.tsx` and render it adjacent to the existing
+ask field when the current input begins with `/`.
+
+Required behavior:
+
+- results filter live as the user types;
+- every row shows `ProviderIcon(providerId)`, exact slash ID, title/description,
+ and enabled/needs-key/unavailable state;
+- tapping an enabled row selects it and leaves focus in the argument input;
+- tapping a needs-key row opens the Firecrawl provider key gate from Plan 003;
+- unavailable rows cannot execute and explain their state accessibly;
+- keyboard/web controls support up/down, Enter, and Escape;
+- native controls support tap, dismissal, and screen-reader labels;
+- the menu stays within safe area/keyboard bounds and uses the existing glass
+ visual language plus the global Inter typography;
+- empty filtered results show a small non-actionable state, not a generic LLM
+ request.
+
+Do not replace the current rotating suggestion rows. Hide or visually de-emphasize
+them only while the slash menu is open to avoid competing tap targets.
+
+**Verify**: render tests cover closed, filtered, selected, disabled, key-required,
+empty, and accessibility states.
+
+### Step 4: Route commands before generic natural language
+
+In `GenOS.routeCommand`, parse slash commands before known-app and generic summon
+routing, but after direct OS navigation commands if the existing ordering needs
+to remain authoritative.
+
+For a recognized enabled command:
+
+- require a present Firecrawl key or show the provider key gate;
+- validate the required inputs from the command contract;
+- launch the provider/workflow `AppDef` through the existing `launch` path so
+ session, minimize, resume, switcher, retry, and close behavior remains shared;
+- never execute a command inside an unrelated active app as a child of that app;
+ a leading slash starts/switches to a provider session;
+- keep the full user argument as the factual task input, while the compact
+ trusted workflow contract comes from `workflowId`.
+
+Unknown slash commands must produce a deterministic menu/error state and must
+not be sent to Cerebras as a generic app request.
+
+**Verify**: route tests assert known command launch metadata, provider/workflow
+identity, missing-key behavior, unknown command behavior, and unchanged natural
+language/OS routing.
+
+### Step 5: Add deterministic onboarding for workflow-specific inputs
+
+Each enabled workflow must define required inputs and a deterministic first
+screen when they are missing:
+
+- company directories: directory URL/name, optional filters, result cap, output
+ view;
+- deep research: topic and mandatory quick/thorough/exhaustive runtime/depth;
+- lead gen: target, source/auth note, lead cap, output view;
+- lead research: company or URL, optional person, meeting context;
+- base Firecrawl: URL or query and explicit operation choice when ambiguous.
+
+Create one shell-owned `WorkflowSetup` form driven by typed field definitions in
+the workflow registry. Support text, URL, bounded number, select, and checkbox
+fields; do not hand-build 17 forms or rely on the LLM to invent required inputs.
+Validate locally, show the selected maximum credit cap, and only then create the
+workflow `AppDef`. Do not start a paid Agent job until required fields and
+budget/depth confirmation are present. Do not ask more than the contract
+requires.
+
+The in-app deliverable is an app screen, so adapt file-oriented skill outputs as:
+
+- a concise summary/current progress screen;
+- structured tables/lists and data-gap sections;
+- source links;
+- follow-up actions for details or next pages.
+
+Do not claim that CSV/JSON was exported until export exists.
+
+**Verify**: tests assert incomplete commands produce onboarding without a
+Firecrawl POST, and complete commands produce exactly one job/search/scrape.
+
+### Step 6: Verify the complete command/session flow
+
+Run full automated tests, then manually exercise:
+
+1. type `/fir` and select `/firecrawl`;
+2. enter a public URL and run a low-cost scrape;
+3. minimize the result and verify Firecrawl favicon on home;
+4. open switcher and verify provider metadata;
+5. launch `/firecrawl-lead-research ` with a low cap;
+6. cancel an in-flight task and verify no duplicate rerun;
+7. remove/reject the key and verify ordinary AppLess prompts still work;
+8. try an unknown command and simulate an unavailable-provider state.
+
+Update README with command syntax, enabled catalog, key/cost warning, and the
+distinction between in-app structured views and future CSV/export support.
+
+**Verify**: `npm run typecheck` and
+`npm test -- --runInBand --no-watchman` both exit 0.
+
+## Test plan
+
+- `__tests__/commands.test.tsx`: registry integrity, parser/filter, menu states,
+ keyboard/tap selection, and accessibility.
+- `__tests__/store.test.ts`: launched app has provider/workflow metadata and
+ children inherit it.
+- Mocked integration test: command -> onboarding -> one tool call -> formatted
+ result screen metadata.
+- Existing render and tool suites remain green.
+- Manual low-credit device checks listed in Step 6.
+
+## Done criteria
+
+- [ ] Typing `/` opens a filtered provider command menu.
+- [ ] The catalog contains every listed Firecrawl workflow and all are runnable
+ when a valid key/provider connection is present.
+- [ ] Commands are enabled only when their runtime contract works.
+- [ ] No paid call starts before required inputs and budget/depth selection.
+- [ ] Unknown commands never fall through to generic Cerebras generation.
+- [ ] Firecrawl sessions retain provider favicon and workflow identity through
+ home and switcher flows.
+- [ ] Missing/rejected Firecrawl credentials do not block ordinary app use.
+- [ ] Typecheck and complete no-Watchman tests pass.
+- [ ] README documents syntax, enabled status, costs, and limitations.
+- [ ] `plans/README.md` status is updated.
+
+## STOP conditions
+
+Stop and report if:
+
+- Plans 002 or 003 are not complete;
+- a listed enabled command has no trusted workflow contract or explicit credit
+ policy;
+- command invocation would require reading local Codex skill files at runtime;
+- the menu cannot remain usable above the native keyboard/safe area;
+- the requested deliverable requires file export or authenticated browser
+ sessions not authorized for this scope.
+
+## Maintenance notes
+
+- The catalog should be generated from typed data, never duplicated across menu,
+ parser, and router.
+- Reviewers should scrutinize exact-match execution, disabled states, key gates,
+ cost confirmation, accessibility, and command behavior inside an active app.
+- When additional Firecrawl workflows are added, add their compact trusted
+ contract and runtime tests before exposing them in the catalog.
diff --git a/plans/README.md b/plans/README.md
new file mode 100644
index 0000000..97208b3
--- /dev/null
+++ b/plans/README.md
@@ -0,0 +1,68 @@
+# Implementation Plans
+
+Generated by the improve skill on 2026-08-16. These plans use the current
+working tree as the baseline, including the uncommitted Inter typography work.
+Execute in the order below unless the dependency notes say otherwise. Each
+executor must read its plan fully, honor its STOP conditions, and update its row
+when done.
+
+## Execution order and status
+
+| Plan | Title | Priority | Effort | Depends on | Status |
+|------|-------|----------|--------|------------|--------|
+| 001 | Finish and verify Linear-style global typography | P1 | S | - | DONE |
+| 002 | Add Raycast-compatible provider favicons | P1 | S | - | DONE |
+| 003 | Add the Firecrawl provider runtime | P1 | L | - | DONE |
+| 004 | Add the Firecrawl slash-command catalog and menu | P1 | M | 002, 003 | DONE |
+
+Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) |
+REJECTED (with one-line rationale)
+
+## Dependency notes
+
+- Plans 001, 002, and 003 can be implemented independently.
+- Plan 004 depends on 002 because command rows and resulting sessions use the
+ provider favicon metadata.
+- Plan 004 depends on 003 because a command must not be discoverable as an
+ enabled action until the corresponding Firecrawl runtime can execute it.
+
+## Baseline verification
+
+- `npm run typecheck` passes at commit `9867af9` plus the current uncommitted
+ working-tree changes.
+- `npm test -- --runInBand --no-watchman` passes: 4 suites, 22 tests.
+- Plain `npm test -- --runInBand` fails in the current managed environment
+ because Watchman cannot change permissions under the user's state directory;
+ this is an environment issue, not a repository test failure. Use
+ `--no-watchman` in every plan.
+
+## Architectural decisions captured by these plans
+
+- Linear's current public CSS uses `Inter Variable` for regular text and
+ `Berkeley Mono` for monospace. AppLess has no code/monospace text role today,
+ so the global application family is Inter; do not apply Berkeley Mono to
+ ordinary UI copy.
+- Raycast's `getFavicon` cannot run inside Expo because it returns Raycast API
+ image types and depends on `@raycast/api`. Reproduce its provider URL behavior
+ in a small React Native resolver instead of adding Raycast packages.
+- The named `firecrawl-*` skills are local agent instruction packages, not code
+ that can be bundled into an Expo application. Represent them as a typed command
+ catalog with distilled workflow contracts, backed by Firecrawl's public API.
+- Firecrawl Agent jobs are asynchronous and credit-bearing. Never expose them as
+ a one-shot synchronous fetch with an unlimited/default credit budget.
+
+## Findings considered and rejected
+
+- Add `@raycast/utils` to the Expo app: rejected because it has an
+ `@raycast/api` peer dependency and its image return type is Raycast-runtime
+ specific.
+- Download Linear's hosted font files from `static.linear.app`: rejected because
+ the repository already uses the openly licensed Expo Inter package, which is
+ safer to redistribute and supports consistent named weights on iOS and
+ Android.
+- Copy local `SKILL.md` files into the app at runtime: rejected because those
+ user-machine paths are unavailable in packaged builds and would couple the app
+ to a Codex installation.
+- Put a shared Firecrawl key in `EXPO_PUBLIC_FIRECRAWL_API_KEY`: rejected for
+ production because Expo public variables are bundled into the client. The app
+ remains BYOK and stores the user's key on-device.
diff --git a/src/genos/GenOS.tsx b/src/genos/GenOS.tsx
index 6e6b29e..76496d0 100644
--- a/src/genos/GenOS.tsx
+++ b/src/genos/GenOS.tsx
@@ -16,17 +16,24 @@ import {
Platform,
Pressable,
ScrollView,
- Text,
View,
useWindowDimensions,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { cerebrasKey } from "../config";
+import { firecrawlKey } from "./providers/firecrawl/key";
import type { AppDef } from "./apps";
import { APPS, DEFAULT_TILE, summonApp } from "./apps";
+import {
+ commandToApp,
+ parseSlashCommand,
+ type SlashCommandDef,
+} from "./commands";
import { genosLibrary } from "./library";
import { HomeScreen } from "./shell/HomeScreen";
import { KeyGate } from "./shell/KeyGate";
+import { ProviderKeyGate } from "./shell/ProviderKeyGate";
+import { WorkflowSetup } from "./shell/WorkflowSetup";
import { Switcher, type RunningApp } from "./shell/Switcher";
import {
cleanLang,
@@ -38,7 +45,10 @@ import {
setActiveScreen,
} from "./store";
import { useCds } from "./theme";
+import { Text } from "./typography";
import { AppsIcon, LucideIcon } from "./ui/icons";
+import { isFirecrawlWorkflow } from "./workflows";
+import type { WorkflowSetupValues } from "./workflows";
/** Shape of the ActionEvent the Renderer dispatches (subset we use). */
interface GenActionEvent {
@@ -51,6 +61,8 @@ interface AppMeta {
name: string;
emoji: string;
tile: [string, string];
+ providerId?: string;
+ workflowId?: string;
}
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
@@ -187,6 +199,7 @@ function Skeleton() {
export default function GenOS() {
useSyncExternalStore(screenStore.subscribe, screenStore.getVersion);
const keyStatus = useSyncExternalStore(cerebrasKey.subscribe, cerebrasKey.getStatus);
+ const firecrawlKeyStatus = useSyncExternalStore(firecrawlKey.subscribe, firecrawlKey.getStatus);
const t = useCds();
const insets = useSafeAreaInsets();
@@ -199,6 +212,9 @@ export default function GenOS() {
const [appMeta, setAppMeta] = useState>({});
const [switcherOpen, setSwitcherOpen] = useState(false);
const [toast, setToast] = useState<{ text: string; key: number } | null>(null);
+ const [dismissedProviderGate, setDismissedProviderGate] = useState(null);
+ const [pendingWorkflow, setPendingWorkflow] = useState<{ command: SlashCommandDef; argument: string } | null>(null);
+ const [pendingProviderGate, setPendingProviderGate] = useState(false);
const toastTimer = useRef | null>(null);
/** Apps the user has sent home - only these show as icons on the home screen. */
@@ -302,7 +318,13 @@ export default function GenOS() {
const launch = useCallback(
(app: AppDef) => {
setNavAnim("launch");
- rememberMeta(app.id, { name: app.name, emoji: app.emoji, tile: app.tile });
+ rememberMeta(app.id, {
+ name: app.name,
+ emoji: app.emoji,
+ tile: app.tile,
+ providerId: app.providerId,
+ workflowId: app.workflowId,
+ });
// openApp touches the screen store (which notifies subscribers), so it
// must run here in the event handler - never inside a setState updater.
if (!sessions[app.id]?.length) {
@@ -372,17 +394,25 @@ export default function GenOS() {
/** genos://open deep link - jump into another app at a specific screen. */
const deepLink = useCallback(
(appId: string, request: string) => {
- const id = openDeepLink(appId, request);
+ const id = openDeepLink(
+ appId,
+ request,
+ top?.workflowId,
+ top?.firecrawlConfirmed,
+ top?.workflowInputs,
+ );
const known = APPS.find((a) => a.id === appId.toLowerCase());
rememberMeta(appId.toLowerCase(), {
name: known?.name ?? capitalize(appId),
emoji: known?.emoji ?? "✨",
tile: known?.tile ?? DEFAULT_TILE,
+ providerId: known?.providerId,
+ workflowId: known?.workflowId,
});
pushScreen(appId.toLowerCase(), id);
activate(appId.toLowerCase());
},
- [activate, rememberMeta, pushScreen],
+ [activate, rememberMeta, pushScreen, top?.workflowId, top?.firecrawlConfirmed, top?.workflowInputs],
);
/**
@@ -515,6 +545,24 @@ export default function GenOS() {
return;
}
+ // Slash execution is exact and precedes all app/model routing. Leading
+ // whitespace is intentionally not a slash command, and unknown slash
+ // input never falls through to an unrelated active app or Cerebras.
+ const slash = parseSlashCommand(transcript);
+ if (slash.kind === "unknown") {
+ showToast(`Unknown slash command: /${slash.commandId || ""}`);
+ return;
+ }
+ if (slash.kind === "known") {
+ if (slash.command.availability === "unavailable") {
+ showToast(`/${slash.command.id} is unavailable`);
+ return;
+ }
+ setPendingWorkflow({ command: slash.command, argument: slash.argument });
+ if (!firecrawlKey.get()) setPendingProviderGate(true);
+ return;
+ }
+
// "open/launch/switch to " jumps to a known app from anywhere.
const openMatch = lower.match(/^(?:open|launch|switch to|go to)\s+(.+)$/);
const known = APPS.find((a) => (openMatch?.[1] ?? lower).includes(a.name.toLowerCase()));
@@ -532,7 +580,7 @@ export default function GenOS() {
request: `Open the perfect app screen for this request: "${text}". Invent a polished, realistic screen that fulfils it.`,
});
},
- [activeApp, topId, launch, goBack, goHome, pushScreen, closeSession],
+ [activeApp, topId, launch, goBack, goHome, pushScreen, closeSession, showToast],
);
// Memoized: HomeScreen is React.memo'd and stays mounted under active
@@ -546,6 +594,8 @@ export default function GenOS() {
name: appMeta[id]?.name ?? capitalize(id),
emoji: appMeta[id]?.emoji ?? "✨",
tile: appMeta[id]?.tile ?? DEFAULT_TILE,
+ providerId: appMeta[id]?.providerId,
+ workflowId: appMeta[id]?.workflowId,
})),
[recentOrder, sessions, appMeta],
);
@@ -566,6 +616,7 @@ export default function GenOS() {
runningApps={homeApps}
onResume={activate}
onClose={closeSession}
+ hasFirecrawlKey={firecrawlKeyStatus === "present"}
/>
{top && (
@@ -771,7 +822,13 @@ export default function GenOS() {
>
- {top?.searching ? "searching the web…" : "materializing…"}
+ {top?.toolProgress?.state === "starting"
+ ? "starting Firecrawl agent…"
+ : top?.toolProgress?.state === "processing"
+ ? `Firecrawl working… ${Math.floor(top.toolProgress.elapsedMs / 1000)}s`
+ : top?.searching
+ ? "searching the web…"
+ : "materializing…"}
)}
@@ -815,6 +872,50 @@ export default function GenOS() {
/>
)}
+ {pendingWorkflow && (
+ {
+ setPendingWorkflow(null);
+ setPendingProviderGate(false);
+ }}
+ onSubmit={(values: WorkflowSetupValues) => {
+ const app = commandToApp(pendingWorkflow.command, pendingWorkflow.argument, values);
+ setPendingWorkflow(null);
+ setPendingProviderGate(false);
+ launch(app);
+ }}
+ />
+ )}
+
+ {pendingProviderGate &&
+ (firecrawlKeyStatus === "missing" || firecrawlKeyStatus === "rejected") && (
+ {
+ setPendingProviderGate(false);
+ setPendingWorkflow(null);
+ }}
+ onConnected={() => setPendingProviderGate(false)}
+ />
+ )}
+
+ {topId &&
+ !pendingProviderGate &&
+ isFirecrawlWorkflow(top?.workflowId) &&
+ (firecrawlKeyStatus === "missing" || firecrawlKeyStatus === "rejected") &&
+ dismissedProviderGate !== topId && (
+ setDismissedProviderGate(topId)}
+ onConnected={() => {
+ setDismissedProviderGate(null);
+ retryScreen(topId);
+ }}
+ />
+ )}
+
{(keyStatus === "missing" || keyStatus === "rejected") && }
);
diff --git a/src/genos/apps.ts b/src/genos/apps.ts
index 6e0abc2..4ae1401 100644
--- a/src/genos/apps.ts
+++ b/src/genos/apps.ts
@@ -2,10 +2,17 @@ export interface AppDef {
id: string;
name: string;
emoji: string;
+ providerId?: string;
/** Gradient stops for the icon tile. */
tile: [string, string];
/** The request sent to the model to open the app's home screen. */
request: string;
+ /** Optional trusted runtime contract. Plan 004 will attach these to catalog entries. */
+ workflowId?: string;
+ /** Validated, non-secret shell inputs retained through workflow navigation. */
+ workflowInputs?: Record;
+ /** Only the deterministic setup form may set this after explicit confirmation. */
+ firecrawlConfirmed?: boolean;
}
export const DEFAULT_TILE: [string, string] = ["#5e5ce6", "#bf5af2"];
diff --git a/src/genos/commands.ts b/src/genos/commands.ts
new file mode 100644
index 0000000..a9f0d3d
--- /dev/null
+++ b/src/genos/commands.ts
@@ -0,0 +1,150 @@
+import type { AppDef } from "./apps";
+import { getProvider } from "./providers";
+import {
+ FIRECRAWL_WORKFLOWS,
+ FIRECRAWL_WORKFLOW_IDS,
+ FIRECRAWL_WORKFLOW_SETUPS,
+ isFirecrawlWorkflow,
+ setupCreditBudget,
+ validateWorkflowSetup,
+ type FirecrawlWorkflowId,
+ type WorkflowSetupValues,
+} from "./workflows";
+
+export const MAX_COMMAND_INPUT = 10_000;
+
+export type SlashCommandAvailability = "enabled" | "needs-key" | "unavailable";
+
+export interface SlashCommandDef {
+ id: FirecrawlWorkflowId;
+ title: string;
+ description: string;
+ providerId: "firecrawl";
+ workflowId: FirecrawlWorkflowId;
+ inputHint: string;
+ keywords: readonly string[];
+ availability: "enabled" | "unavailable";
+}
+
+const COMMAND_COPY: Record> = {
+ firecrawl: { title: "Firecrawl", description: "Choose a bounded scrape, search, or Agent task", inputHint: "URL or query", keywords: ["scrape", "search", "agent"] },
+ "firecrawl-company-directories": { title: "Company directories", description: "Extract bounded, deduplicated public directory records", inputHint: "directory URL or name", keywords: ["directory", "companies", "list"] },
+ "firecrawl-competitive-intel": { title: "Competitive intel", description: "Compare current pricing, features, and product evidence", inputHint: "competitors and scope", keywords: ["competitor", "pricing", "features"] },
+ "firecrawl-dashboard-reporting": { title: "Dashboard reporting", description: "Summarize an authorized dashboard boundary", inputHint: "authorized dashboard URL", keywords: ["dashboard", "metrics", "report"] },
+ "firecrawl-deep-research": { title: "Deep research", description: "Run cited research at an explicit depth and budget", inputHint: "research topic", keywords: ["research", "citations", "synthesis"] },
+ "firecrawl-demo-walkthrough": { title: "Demo walkthrough", description: "Observe a bounded public product flow without changes", inputHint: "public URL and flow", keywords: ["demo", "walkthrough", "ux"] },
+ "firecrawl-knowledge-base": { title: "Knowledge base", description: "Build an in-app sourced knowledge view", inputHint: "source URLs and boundary", keywords: ["knowledge", "docs", "crawl"] },
+ "firecrawl-knowledge-ingest": { title: "Knowledge ingest", description: "Normalize bounded sources into an in-app view", inputHint: "source URLs", keywords: ["ingest", "normalize", "dedupe"] },
+ "firecrawl-lead-gen": { title: "Lead generation", description: "Find bounded public leads with explicit data gaps", inputHint: "target audience", keywords: ["leads", "prospects", "companies"] },
+ "firecrawl-lead-research": { title: "Lead research", description: "Create a concise sourced company or person brief", inputHint: "company or URL", keywords: ["lead", "company", "person", "brief"] },
+ "firecrawl-market-research": { title: "Market research", description: "Research dated market evidence and uncertainty", inputHint: "market and question", keywords: ["market", "metrics", "methodology"] },
+ "firecrawl-qa": { title: "QA", description: "Run a bounded, read-only website test charter", inputHint: "target URL and charter", keywords: ["qa", "test", "bugs"] },
+ "firecrawl-research-papers": { title: "Research papers", description: "Find primary papers with methods and limitations", inputHint: "paper topic", keywords: ["papers", "academic", "pdf"] },
+ "firecrawl-seo-audit": { title: "SEO audit", description: "Audit bounded metadata and indexability evidence", inputHint: "site URL", keywords: ["seo", "metadata", "indexability"] },
+ "firecrawl-shop": { title: "Shop", description: "Compare current public price and availability evidence", inputHint: "product and constraints", keywords: ["shopping", "price", "products"] },
+ "firecrawl-website-design-clone": { title: "Website design study", description: "Document observed design tokens and components", inputHint: "target page URL", keywords: ["website", "design", "tokens", "components"] },
+ "firecrawl-workflows": { title: "Workflow chooser", description: "Choose one concrete bounded Firecrawl workflow", inputHint: "your goal", keywords: ["workflow", "chooser", "route"] },
+};
+
+const FIRECRAWL_COMMAND_IDS: readonly FirecrawlWorkflowId[] = [
+ "firecrawl",
+ ...FIRECRAWL_WORKFLOW_IDS.filter((id) => id !== "firecrawl"),
+];
+
+export const FIRECRAWL_COMMANDS: readonly SlashCommandDef[] = FIRECRAWL_COMMAND_IDS.map((id) => ({
+ id,
+ providerId: "firecrawl" as const,
+ workflowId: id,
+ ...COMMAND_COPY[id],
+ availability:
+ getProvider("firecrawl") && FIRECRAWL_WORKFLOWS[id] && FIRECRAWL_WORKFLOW_SETUPS[id]
+ ? "enabled"
+ : "unavailable",
+}));
+
+export type SlashParseResult =
+ | { kind: "none" }
+ | { kind: "unknown"; commandId: string; argument: string }
+ | { kind: "known"; command: SlashCommandDef; argument: string };
+
+/** Exact execution parser. Prefix matches are deliberately never executable. */
+export function parseSlashCommand(text: string): SlashParseResult {
+ if (!text.startsWith("/")) return { kind: "none" };
+ const match = text.match(/^\/([^\s]*)(?:\s([\s\S]*))?$/);
+ if (!match) return { kind: "unknown", commandId: text.slice(1), argument: "" };
+ const commandId = match[1].toLowerCase();
+ const argument = (match[2] ?? "").slice(0, MAX_COMMAND_INPUT);
+ const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === commandId);
+ return command
+ ? { kind: "known", command, argument }
+ : { kind: "unknown", commandId, argument };
+}
+
+/** Discovery helper. Partial matches filter only; route execution uses the parser above. */
+export function filterSlashCommands(text: string): readonly SlashCommandDef[] {
+ if (!text.startsWith("/")) return [];
+ const query = text.slice(1).split(/\s/, 1)[0].toLowerCase();
+ if (!query) return FIRECRAWL_COMMANDS;
+ return FIRECRAWL_COMMANDS.filter((command) =>
+ [command.id, command.title, command.description, ...command.keywords]
+ .join(" ")
+ .toLowerCase()
+ .includes(query),
+ );
+}
+
+export function commandAvailability(
+ command: SlashCommandDef,
+ hasProviderKey: boolean,
+): SlashCommandAvailability {
+ if (command.availability === "unavailable" || !isFirecrawlWorkflow(command.workflowId)) return "unavailable";
+ return hasProviderKey ? "enabled" : "needs-key";
+}
+
+function stableHash(value: string): string {
+ let hash = 2166136261;
+ for (let index = 0; index < value.length; index += 1) {
+ hash ^= value.charCodeAt(index);
+ hash = Math.imul(hash, 16777619);
+ }
+ return (hash >>> 0).toString(36);
+}
+
+export function commandToApp(
+ command: SlashCommandDef,
+ argument: string,
+ setupValues: WorkflowSetupValues = {},
+): AppDef {
+ const validationErrors = validateWorkflowSetup(command.workflowId, setupValues);
+ if (Object.keys(validationErrors).length > 0) {
+ throw new Error("Workflow setup must be complete before launch");
+ }
+ const safeArgument = argument.slice(0, MAX_COMMAND_INPUT);
+ const safeValues = Object.fromEntries(
+ Object.entries(setupValues).filter(([key]) => !/key|secret|token|password/i.test(key)),
+ );
+ const budget = setupCreditBudget(command.workflowId, safeValues);
+ const operation = command.workflowId === "firecrawl" ? safeValues.operation : "agent";
+ const factualTask = safeArgument ? `Original command argument: ${safeArgument}\n` : "";
+ const request = [
+ `Run the trusted ${command.workflowId} workflow.`,
+ factualTask,
+ `Validated setup inputs: ${JSON.stringify(safeValues)}`,
+ `The user explicitly confirmed the central maximum budget of ${budget} credits.`,
+ `Use exactly one confirmed Firecrawl ${operation} operation for this workflow.`,
+ "For an Agent tool call, pass the selected depth and confirmCost: true; never exceed the workflow-owned central policy.",
+ "Render an in-app summary/current-progress view with structured lists or tables, data gaps, and source links.",
+ "Do not claim a CSV, JSON, file, CRM record, outreach message, purchase, or export was created.",
+ ].filter(Boolean).join("\n");
+ return {
+ id: `workflow-${command.id}-${stableHash(JSON.stringify([safeArgument, safeValues]))}`,
+ name: command.title,
+ emoji: "🔥",
+ providerId: command.providerId,
+ workflowId: command.workflowId,
+ workflowInputs: safeValues,
+ firecrawlConfirmed: true,
+ tile: ["#fa5d3b", "#f59e0b"],
+ request,
+ };
+}
diff --git a/src/genos/providers.ts b/src/genos/providers.ts
new file mode 100644
index 0000000..fba0ec9
--- /dev/null
+++ b/src/genos/providers.ts
@@ -0,0 +1,38 @@
+export interface ProviderDef {
+ id: string;
+ name: string;
+ domain: string;
+ homepage: string;
+ tile: [string, string];
+ fallbackGlyph: string;
+}
+
+const PROVIDERS: Record = {
+ firecrawl: {
+ id: "firecrawl",
+ name: "Firecrawl",
+ domain: "firecrawl.dev",
+ homepage: "https://firecrawl.dev",
+ tile: ["#fa5d3b", "#f59e0b"],
+ fallbackGlyph: "globe",
+ },
+};
+
+export function getProvider(id: string): ProviderDef | undefined {
+ return Object.prototype.hasOwnProperty.call(PROVIDERS, id) ? PROVIDERS[id] : undefined;
+}
+
+export function raycastFaviconUrl(domainOrUrl: string, size = 64): string | null {
+ try {
+ const input = domainOrUrl.trim();
+ if (!input) return null;
+
+ const url = new URL(input.includes("://") ? input : `https://${input}`);
+ if (url.protocol !== "https:" || !url.hostname) return null;
+
+ const safeSize = Number.isFinite(size) ? Math.min(256, Math.max(16, Math.trunc(size))) : 64;
+ return `https://api.ray.so/favicon?url=${encodeURIComponent(url.hostname)}&size=${safeSize}`;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/genos/providers/firecrawl/key.ts b/src/genos/providers/firecrawl/key.ts
new file mode 100644
index 0000000..0efecab
--- /dev/null
+++ b/src/genos/providers/firecrawl/key.ts
@@ -0,0 +1,96 @@
+import * as SecureStore from "expo-secure-store";
+import { Platform } from "react-native";
+
+export type FirecrawlKeyStatus = "loading" | "missing" | "present" | "rejected";
+
+const STORAGE_KEY = "genos.firecrawl-key";
+const ENV_KEY = __DEV__ ? process.env.EXPO_PUBLIC_FIRECRAWL_API_KEY : undefined;
+
+interface KeyPersistence {
+ read(): Promise;
+ write(value: string | null): Promise;
+}
+
+const persistence: KeyPersistence = {
+ async read() {
+ if (Platform.OS === "web") {
+ try {
+ return globalThis.localStorage?.getItem(STORAGE_KEY) ?? null;
+ } catch {
+ return null;
+ }
+ }
+ return SecureStore.getItemAsync(STORAGE_KEY);
+ },
+ async write(value) {
+ if (Platform.OS === "web") {
+ try {
+ if (value === null) globalThis.localStorage?.removeItem(STORAGE_KEY);
+ else globalThis.localStorage?.setItem(STORAGE_KEY, value);
+ } catch {
+ // Private-mode storage can be unavailable; retain the in-memory key.
+ }
+ return;
+ }
+ if (value === null) await SecureStore.deleteItemAsync(STORAGE_KEY);
+ else await SecureStore.setItemAsync(STORAGE_KEY, value);
+ },
+};
+
+export class FirecrawlKeyStore {
+ private key: string | null;
+ private status: FirecrawlKeyStatus;
+ private listeners = new Set<() => void>();
+
+ constructor(
+ private readonly storage: KeyPersistence = persistence,
+ environmentKey: string | undefined = ENV_KEY,
+ ) {
+ this.key = environmentKey?.trim() || null;
+ this.status = this.key ? "present" : "loading";
+ if (!this.key) void this.hydrate();
+ }
+
+ private async hydrate() {
+ try {
+ const stored = await this.storage.read();
+ if (this.status !== "loading") return;
+ this.key = stored?.trim() || null;
+ this.setStatus(this.key ? "present" : "missing");
+ } catch {
+ if (this.status === "loading") this.setStatus("missing");
+ }
+ }
+
+ subscribe = (listener: () => void) => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ };
+
+ getStatus = () => this.status;
+
+ get(): string | null {
+ return this.key;
+ }
+
+ async set(value: string) {
+ this.key = value.trim() || null;
+ this.setStatus(this.key ? "present" : "missing");
+ await this.storage.write(this.key).catch(() => {});
+ }
+
+ markRejected(rejectedKey: string) {
+ if (this.key !== rejectedKey) return;
+ this.key = null;
+ this.setStatus("rejected");
+ void this.storage.write(null).catch(() => {});
+ }
+
+ private setStatus(status: FirecrawlKeyStatus) {
+ this.status = status;
+ this.listeners.forEach((listener) => listener());
+ }
+}
+
+/** Firecrawl credentials are deliberately independent from the Cerebras gate. */
+export const firecrawlKey = new FirecrawlKeyStore();
diff --git a/src/genos/shell/CommandMenu.tsx b/src/genos/shell/CommandMenu.tsx
new file mode 100644
index 0000000..2c2d584
--- /dev/null
+++ b/src/genos/shell/CommandMenu.tsx
@@ -0,0 +1,105 @@
+import React from "react";
+import { Pressable, ScrollView, View } from "react-native";
+import {
+ commandAvailability,
+ filterSlashCommands,
+ type SlashCommandDef,
+} from "../commands";
+import { Text, linearType } from "../typography";
+import { ProviderIcon } from "../ui/ProviderIcon";
+
+export function moveCommandSelection(current: number, delta: number, count: number): number {
+ if (count <= 0) return -1;
+ return (Math.max(0, current) + delta + count) % count;
+}
+
+export function CommandMenu({
+ text,
+ hasProviderKey,
+ highlightedIndex,
+ onHighlightedIndexChange,
+ onSelect,
+ onNeedsKey,
+ onDismiss,
+}: {
+ text: string;
+ hasProviderKey: boolean;
+ highlightedIndex: number;
+ onHighlightedIndexChange: (index: number) => void;
+ onSelect: (command: SlashCommandDef) => void;
+ onNeedsKey: (command: SlashCommandDef) => void;
+ onDismiss: () => void;
+}) {
+ const commands = filterSlashCommands(text);
+
+ return (
+
+
+
+ FIRECRAWL COMMANDS · exact command + Enter to run
+
+
+ Escape
+
+
+ {commands.length === 0 ? (
+
+
+ No matching Firecrawl command. This text will not be sent as a generic request.
+
+
+ ) : (
+
+ {commands.map((command, index) => {
+ const availability = commandAvailability(command, hasProviderKey);
+ const unavailable = availability === "unavailable";
+ const label = availability === "enabled" ? "Ready" : availability === "needs-key" ? "Connect key" : "Unavailable";
+ return (
+ onHighlightedIndexChange(index)}
+ onPress={() => availability === "needs-key" ? onNeedsKey(command) : onSelect(command)}
+ style={({ pressed }) => ({
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 11,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ backgroundColor: index === highlightedIndex ? "rgba(255,255,255,0.12)" : "transparent",
+ opacity: unavailable ? 0.45 : pressed ? 0.65 : 1,
+ })}
+ >
+
+
+
+ /{command.id}
+
+
+ {command.description}
+
+
+
+ {label}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/genos/shell/HomeScreen.tsx b/src/genos/shell/HomeScreen.tsx
index 26e7207..77e7f96 100644
--- a/src/genos/shell/HomeScreen.tsx
+++ b/src/genos/shell/HomeScreen.tsx
@@ -28,17 +28,26 @@ import React, { useEffect, useRef, useState } from "react";
import {
Animated,
ImageBackground,
+ type NativeSyntheticEvent,
Platform,
Pressable,
ScrollView,
- Text,
- TextInput,
+ TextInput as NativeTextInput,
+ type TextInputKeyPressEventData,
View,
} from "react-native";
import { SvgXml } from "react-native-svg";
import type { Suggestion } from "../apps";
import { SUGGESTIONS } from "../apps";
+import {
+ commandAvailability,
+ filterSlashCommands,
+ parseSlashCommand,
+} from "../commands";
+import { Text, TextInput, linearType } from "../typography";
+import { ProviderIcon } from "../ui/ProviderIcon";
import { APPLESS_LOGO_XML } from "./applessLogo";
+import { CommandMenu, moveCommandSelection } from "./CommandMenu";
import type { RunningApp } from "./Switcher";
const SUGGESTION_ICONS: Record = {
@@ -117,7 +126,7 @@ function SuggestionRow({
style={{ flexDirection: "row", alignItems: "center", gap: 10, opacity: fade }}
>
-
+
{shown}
@@ -251,7 +260,11 @@ function AppIcon({
},
]}
>
-
+ {app.providerId ? (
+
+ ) : (
+
+ )}
{editing && (
void;
onClose: (appId: string) => void;
+ hasFirecrawlKey: boolean;
}) {
const [ask, setAsk] = useState("");
const [slots, setSlots] = useState([0, 1, 2]);
@@ -322,6 +337,10 @@ export const HomeScreen = React.memo(function HomeScreen({
const [editingId, setEditingId] = useState(null);
const nextIdx = useRef(3);
const turn = useRef(0);
+ const inputRef = useRef(null);
+ const [highlightedCommand, setHighlightedCommand] = useState(0);
+ const menuOpen = ask.startsWith("/");
+ const filteredCommands = filterSlashCommands(ask);
// Every 4s swap out one suggestion (cycling through the rows in order).
// Paused while covered - no point animating under an app screen. The refs
@@ -343,12 +362,36 @@ export const HomeScreen = React.memo(function HomeScreen({
const visible: Suggestion[] = slots.map((i) => SUGGESTIONS[i]);
const submit = () => {
- const text = ask.trim();
+ const text = ask;
if (!text) return;
+ const parsed = parseSlashCommand(text);
+ if (menuOpen && parsed.kind !== "known" && filteredCommands[highlightedCommand]) {
+ const command = filteredCommands[highlightedCommand];
+ if (commandAvailability(command, hasFirecrawlKey) === "needs-key") {
+ onCommand(`/${command.id}`);
+ setAsk("");
+ } else {
+ setAsk(`/${command.id} `);
+ requestAnimationFrame(() => inputRef.current?.focus());
+ }
+ return;
+ }
onCommand(text);
setAsk("");
};
+ const handleKeyPress = (event: NativeSyntheticEvent) => {
+ if (!menuOpen) return;
+ const key = event.nativeEvent.key;
+ if (key === "ArrowDown" || key === "ArrowUp") {
+ event.preventDefault?.();
+ setHighlightedCommand((current) => moveCommandSelection(current, key === "ArrowDown" ? 1 : -1, filteredCommands.length));
+ } else if (key === "Escape") {
+ event.preventDefault?.();
+ setAsk("");
+ }
+ };
+
return (
-
- {visible.map((s, i) => (
- onCommand(s.command)} />
- ))}
-
+ {!menuOpen && (
+
+ {visible.map((s, i) => (
+ onCommand(s.command)} />
+ ))}
+
+ )}
+
+ {menuOpen && (
+ {
+ setAsk(`/${command.id} `);
+ requestAnimationFrame(() => inputRef.current?.focus());
+ }}
+ onNeedsKey={(command) => {
+ onCommand(`/${command.id}`);
+ setAsk("");
+ }}
+ onDismiss={() => setAsk("")}
+ />
+ )}
{
+ setAsk(value);
+ setHighlightedCommand(0);
+ }}
+ onKeyPress={handleKeyPress}
onSubmitEditing={submit}
returnKeyType="go"
placeholder="Ask for anything…"
diff --git a/src/genos/shell/KeyGate.tsx b/src/genos/shell/KeyGate.tsx
index 4a07c34..751969d 100644
--- a/src/genos/shell/KeyGate.tsx
+++ b/src/genos/shell/KeyGate.tsx
@@ -5,9 +5,10 @@
* API rejects the stored key.
*/
import React, { useState } from "react";
-import { Linking, Pressable, Text, TextInput, View } from "react-native";
+import { Linking, Pressable, View } from "react-native";
import { cerebrasKey, type KeyStatus } from "../../config";
import { useCds } from "../theme";
+import { Text, TextInput, linearType } from "../typography";
const ACCENT = "#5e5ce6";
@@ -39,7 +40,7 @@ export function KeyGate({ status }: { status: KeyStatus }) {
gap: 14,
}}
>
-
+
AppLess
diff --git a/src/genos/shell/ProviderKeyGate.tsx b/src/genos/shell/ProviderKeyGate.tsx
new file mode 100644
index 0000000..efcc062
--- /dev/null
+++ b/src/genos/shell/ProviderKeyGate.tsx
@@ -0,0 +1,96 @@
+import React, { useState } from "react";
+import { Linking, Pressable, View } from "react-native";
+import { firecrawlKey, type FirecrawlKeyStatus } from "../providers/firecrawl/key";
+import { useCds } from "../theme";
+import { Text, TextInput, linearType } from "../typography";
+
+export function ProviderKeyGate({
+ status,
+ onDismiss,
+ onConnected,
+}: {
+ status: FirecrawlKeyStatus;
+ onDismiss: () => void;
+ onConnected: () => void;
+}) {
+ const t = useCds();
+ const [value, setValue] = useState("");
+ const [saving, setSaving] = useState(false);
+ const valid = value.trim().length >= 10;
+
+ const save = async () => {
+ if (!valid || saving) return;
+ setSaving(true);
+ await firecrawlKey.set(value);
+ setSaving(false);
+ onConnected();
+ };
+
+ return (
+
+ Connect Firecrawl
+
+ This action uses your Firecrawl credits. Your key is stored only on this device and sent
+ directly to Firecrawl.
+
+ {status === "rejected" && (
+
+ Firecrawl rejected the saved key. Enter a current key.
+
+ )}
+
+ ({
+ paddingVertical: 12,
+ paddingHorizontal: 34,
+ borderRadius: 22,
+ backgroundColor: "#5e5ce6",
+ opacity: !valid || saving ? 0.4 : pressed ? 0.8 : 1,
+ })}
+ >
+
+ {saving ? "Saving…" : "Use Firecrawl"}
+
+
+ Linking.openURL("https://www.firecrawl.dev/app/api-keys").catch(() => {})}>
+ Get a key from Firecrawl
+
+
+ Not now
+
+
+ );
+}
diff --git a/src/genos/shell/Switcher.tsx b/src/genos/shell/Switcher.tsx
index f972438..5d33afc 100644
--- a/src/genos/shell/Switcher.tsx
+++ b/src/genos/shell/Switcher.tsx
@@ -1,16 +1,20 @@
import { Renderer } from "@openuidev/react-lang";
import { LinearGradient } from "expo-linear-gradient";
import React from "react";
-import { Pressable, ScrollView, Text, View } from "react-native";
+import { Pressable, ScrollView, View } from "react-native";
import { genosLibrary } from "../library";
import { cleanLang, screenStore } from "../store";
import { useCds } from "../theme";
+import { Text } from "../typography";
+import { ProviderIcon } from "../ui/ProviderIcon";
export interface RunningApp {
id: string;
name: string;
emoji: string;
tile: [string, string];
+ providerId?: string;
+ workflowId?: string;
}
const CARD_W = 188;
@@ -62,22 +66,26 @@ export function Switcher({
const screenId = topScreenId(app.id);
const screen = screenId ? screenStore.get(screenId) : undefined;
return (
-
+
-
- {app.emoji}
-
+ {app.providerId ? (
+
+ ) : (
+
+ {app.emoji}
+
+ )}
✕
+ {!!app.workflowId && (
+
+ /{app.workflowId}
+
+ )}
onResume(app.id)}
style={{
@@ -135,7 +148,11 @@ export function Switcher({
) : (
- {app.emoji}
+ {app.providerId ? (
+
+ ) : (
+ {app.emoji}
+ )}
)}
diff --git a/src/genos/shell/WorkflowSetup.tsx b/src/genos/shell/WorkflowSetup.tsx
new file mode 100644
index 0000000..df41727
--- /dev/null
+++ b/src/genos/shell/WorkflowSetup.tsx
@@ -0,0 +1,168 @@
+import React, { useMemo, useState } from "react";
+import { Pressable, ScrollView, View } from "react-native";
+import type { SlashCommandDef } from "../commands";
+import {
+ FIRECRAWL_WORKFLOW_SETUPS,
+ setupCreditBudget,
+ validateWorkflowSetup,
+ visibleWorkflowFields,
+ type WorkflowFieldDef,
+ type WorkflowSetupValues,
+} from "../workflows";
+import { useCds } from "../theme";
+import { Text, TextInput, linearType } from "../typography";
+import { ProviderIcon } from "../ui/ProviderIcon";
+
+export function initialWorkflowValues(command: SlashCommandDef, argument: string): WorkflowSetupValues {
+ const values: WorkflowSetupValues = {};
+ const fields = FIRECRAWL_WORKFLOW_SETUPS[command.workflowId].fields;
+ const firstTaskField = fields.find((candidate) =>
+ candidate.required && (candidate.type === "text" || candidate.type === "url") && !candidate.visibleWhen,
+ );
+ if (firstTaskField && argument.trim()) values[firstTaskField.id] = argument.slice(0, 10_000);
+ for (const candidate of fields) {
+ if (candidate.type === "select" && candidate.options?.length) values[candidate.id] = candidate.options[0].value;
+ if (candidate.type === "number" && candidate.required) values[candidate.id] = candidate.min ?? 1;
+ if (candidate.type === "checkbox") values[candidate.id] = false;
+ }
+ if (command.workflowId === "firecrawl" && argument.trim()) {
+ try {
+ const url = new URL(argument.trim());
+ if (url.protocol === "http:" || url.protocol === "https:") {
+ values.operation = "scrape";
+ values.url = argument.trim();
+ }
+ } catch {
+ values.operation = "search";
+ values.query = argument.slice(0, 10_000);
+ }
+ } else if (command.workflowId === "firecrawl") {
+ delete values.operation;
+ }
+ return values;
+}
+
+function Field({
+ definition,
+ value,
+ error,
+ onChange,
+}: {
+ definition: WorkflowFieldDef;
+ value: string | number | boolean | undefined;
+ error?: string;
+ onChange: (value: string | number | boolean) => void;
+}) {
+ const t = useCds();
+ if (definition.type === "checkbox") {
+ return (
+ onChange(value !== true)}
+ style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, paddingVertical: 8 }}
+ >
+
+ {value === true && ✓}
+
+
+ {definition.label}
+ {!!definition.hint && {definition.hint}}
+ {!!error && {error}}
+
+
+ );
+ }
+ if (definition.type === "select") {
+ return (
+
+ {definition.label}
+
+ {definition.options?.map((option) => (
+ onChange(option.value)}
+ style={{ paddingVertical: 7, paddingHorizontal: 11, borderRadius: 14, backgroundColor: value === option.value ? t.tint : t.fill }}
+ >
+ {option.label}
+
+ ))}
+
+ {!!error && {error}}
+
+ );
+ }
+ return (
+
+ {definition.label}
+ onChange(definition.type === "number" ? next : next.slice(0, 10_000))}
+ placeholder={definition.placeholder}
+ placeholderTextColor={t.ink3}
+ autoCapitalize="none"
+ keyboardType={definition.type === "number" ? "number-pad" : definition.type === "url" ? "url" : "default"}
+ style={{ borderWidth: 1, borderColor: error ? t.red : t.sep, backgroundColor: t.group, borderRadius: 11, paddingVertical: 10, paddingHorizontal: 12, color: t.ink, fontSize: 13 }}
+ />
+ {!!error && {error}}
+
+ );
+}
+
+export function WorkflowSetup({ command, argument, onCancel, onSubmit }: {
+ command: SlashCommandDef;
+ argument: string;
+ onCancel: () => void;
+ onSubmit: (values: WorkflowSetupValues) => void;
+}) {
+ const t = useCds();
+ const [values, setValues] = useState(() => initialWorkflowValues(command, argument));
+ const [errors, setErrors] = useState>({});
+ const fields = useMemo(() => visibleWorkflowFields(command.workflowId, values), [command.workflowId, values]);
+ const budget = setupCreditBudget(command.workflowId, values);
+
+ const submit = () => {
+ const nextErrors = validateWorkflowSetup(command.workflowId, values);
+ setErrors(nextErrors);
+ if (Object.keys(nextErrors).length === 0) onSubmit(values);
+ };
+
+ return (
+
+
+
+
+
+ {command.title}
+ Deterministic workflow setup
+
+
+
+ Complete the required inputs below. Nothing is sent to Firecrawl until you confirm the displayed budget and press Run.
+
+ {fields.map((definition) => (
+ setValues((current) => ({ ...current, [definition.id]: value }))} />
+ ))}
+
+ Maximum budget: {budget} credits
+ This is a ceiling, not expected spend. Exhaustive research can use up to 750 credits.
+
+
+
+ Cancel
+
+
+ Run workflow
+
+
+
+ Results stay in AppLess as structured views with sources. File/CSV export is not available.
+
+
+
+ );
+}
diff --git a/src/genos/store.ts b/src/genos/store.ts
index 5d943e1..17390e1 100644
--- a/src/genos/store.ts
+++ b/src/genos/store.ts
@@ -2,6 +2,8 @@ import type { AppDef } from "./apps";
import { APPS } from "./apps";
import type { ChatMessage } from "./stream";
import { streamScreen } from "./stream";
+import { firecrawlKey } from "./providers/firecrawl/key";
+import { isFirecrawlWorkflow } from "./workflows";
export type ScreenStatus = "pending" | "streaming" | "done" | "error";
@@ -12,6 +14,10 @@ export interface Screen {
/** The user-intent message that produced this screen. */
request: string;
parentId?: string;
+ /** Trusted provider workflow inherited by every descendant screen. */
+ workflowId?: string;
+ workflowInputs?: Record;
+ firecrawlConfirmed?: boolean;
/** Accumulating openui-lang source. */
content: string;
status: ScreenStatus;
@@ -30,6 +36,7 @@ export interface Screen {
osCommand?: { cmd: "back" | "home" | "switcher" | "open"; arg?: string };
/** The model is running tools (web_search) before composing the screen. */
searching?: boolean;
+ toolProgress?: { state: "starting" | "processing"; elapsedMs: number; jobId?: string };
}
/** Detect a whole-response @OS(...) command (nothing else in the reply). */
@@ -97,7 +104,13 @@ class ScreenStore {
// Content is updated synchronously so get()/onDone always see the latest;
// only the subscriber notification is throttled. Content flowing also
// means any tool round is over - flip the "searching" pill back.
- this.screens.set(id, { ...s, content: s.content + delta, status: "streaming", searching: false });
+ this.screens.set(id, {
+ ...s,
+ content: s.content + delta,
+ status: "streaming",
+ searching: false,
+ toolProgress: undefined,
+ });
this.scheduleFlush();
}
@@ -205,6 +218,14 @@ function startStream(id: string) {
streamScreen(buildMessages(screen), {
signal: controller.signal,
+ workflowId: screen.workflowId,
+ firecrawlConfirmed: screen.firecrawlConfirmed,
+ firecrawlOperation:
+ screen.workflowId === "firecrawl"
+ ? (screen.workflowInputs?.operation as "search" | "scrape" | "agent" | undefined)
+ : isFirecrawlWorkflow(screen.workflowId)
+ ? "agent"
+ : undefined,
onDelta: (delta) => {
if (!stale()) screenStore.append(id, delta);
},
@@ -218,6 +239,9 @@ function startStream(id: string) {
screenStore.patch(id, { content: "", status: "pending", searching: true });
return "continue";
},
+ onToolProgress: (toolProgress) => {
+ if (!stale()) screenStore.patch(id, { searching: true, toolProgress });
+ },
onDone: (info) => {
if (stale()) return;
inflight.delete(id);
@@ -229,6 +253,7 @@ function startStream(id: string) {
status: "error",
error: "The connection dropped mid-screen - retry",
searching: false,
+ toolProgress: undefined,
});
return;
}
@@ -240,13 +265,19 @@ function startStream(id: string) {
truncated: info.truncated,
osCommand,
searching: false,
+ toolProgress: undefined,
});
if (!osCommand) maybePrefetch(id);
},
onError: (err) => {
if (stale()) return;
inflight.delete(id);
- screenStore.patch(id, { status: "error", error: err.message, searching: false });
+ screenStore.patch(id, {
+ status: "error",
+ error: err.message,
+ searching: false,
+ toolProgress: undefined,
+ });
},
});
}
@@ -256,6 +287,9 @@ interface LaunchInput {
appName: string;
request: string;
parentId?: string;
+ workflowId?: string;
+ workflowInputs?: Record;
+ firecrawlConfirmed?: boolean;
speculative: boolean;
}
@@ -268,7 +302,9 @@ function launchScreen(input: LaunchInput): string {
status: "pending",
startedAt: performance.now(),
});
- startStream(id);
+ // A Firecrawl workflow waits for its provider-scoped gate. Ordinary apps
+ // continue to launch even when no Firecrawl credential exists.
+ if (!isFirecrawlWorkflow(input.workflowId) || firecrawlKey.get()) startStream(id);
return id;
}
@@ -300,6 +336,9 @@ export function openApp(app: AppDef): string {
appId: app.id,
appName: app.name,
request: app.request,
+ workflowId: app.workflowId,
+ workflowInputs: app.workflowInputs,
+ firecrawlConfirmed: app.firecrawlConfirmed,
speculative: false,
});
appHomeIndex.set(app.id, id);
@@ -310,8 +349,14 @@ export function openApp(app: AppDef): string {
const deepLinkIndex = new Map();
/** Open a screen in another app via a genos://open deep link. */
-export function openDeepLink(appId: string, request: string): string {
- const key = `${appId.toLowerCase()} ${request}`;
+export function openDeepLink(
+ appId: string,
+ request: string,
+ parentWorkflowId?: string,
+ firecrawlConfirmed = false,
+ workflowInputs?: Record,
+): string {
+ const key = `${appId.toLowerCase()} ${parentWorkflowId ?? ""} ${firecrawlConfirmed ? "confirmed" : "unconfirmed"} ${JSON.stringify(workflowInputs ?? {})} ${request}`;
const existing = deepLinkIndex.get(key);
if (existing) {
const screen = screenStore.get(existing);
@@ -326,6 +371,9 @@ export function openDeepLink(appId: string, request: string): string {
appId: app?.id ?? appId.toLowerCase(),
appName: app?.name ?? appId.charAt(0).toUpperCase() + appId.slice(1),
request,
+ workflowId: parentWorkflowId ?? app?.workflowId,
+ workflowInputs: workflowInputs ?? app?.workflowInputs,
+ firecrawlConfirmed: firecrawlConfirmed || app?.firecrawlConfirmed,
speculative: false,
});
deepLinkIndex.set(key, id);
@@ -372,6 +420,9 @@ export function resolveAction(
appName: parent?.appName ?? "App",
request,
parentId,
+ workflowId: parent?.workflowId,
+ workflowInputs: parent?.workflowInputs,
+ firecrawlConfirmed: parent?.firecrawlConfirmed,
speculative: false,
});
if (!hasFormValues) actionIndex.set(key, id);
@@ -394,6 +445,7 @@ export function retryScreen(id: string) {
// tools for prefetched screens that errored with NEEDS_LIVE_DATA.
speculative: false,
searching: false,
+ toolProgress: undefined,
});
startStream(id);
}
@@ -421,9 +473,11 @@ function maybePrefetch(id: string) {
appName: screen.appName,
request: message,
parentId: id,
+ workflowId: screen.workflowId,
+ workflowInputs: screen.workflowInputs,
+ firecrawlConfirmed: screen.firecrawlConfirmed,
speculative: true,
});
actionIndex.set(key, childId);
}
}
-
diff --git a/src/genos/stream.ts b/src/genos/stream.ts
index 33e24d9..7aae9e0 100644
--- a/src/genos/stream.ts
+++ b/src/genos/stream.ts
@@ -11,7 +11,8 @@
import { fetch as expoFetch } from "expo/fetch";
import { CEREBRAS_BASE_URL, GENOS_MODEL, cerebrasKey } from "../config";
import { SYSTEM_PROMPT } from "./generated/system-prompt";
-import { TOOLS_PROMPT_SECTION, TOOL_DEFS, executeTool, toolsAvailable } from "./tools/search";
+import { enabledPromptSections, enabledToolDefinitions, executeTool, isFirecrawlTool, toolsAvailable } from "./tools";
+import { workflowPrompt } from "./workflows";
export interface ToolCall {
id: string;
@@ -49,6 +50,10 @@ interface StreamHandlers {
* actually opens); "continue" executes the calls and streams the next round.
*/
onToolRound?: (calls: Array<{ name: string; args: Record }>) => "continue" | "abort";
+ onToolProgress?: (progress: { state: "starting" | "processing"; elapsedMs: number; jobId?: string }) => void;
+ workflowId?: string;
+ firecrawlConfirmed?: boolean;
+ firecrawlOperation?: "search" | "scrape" | "agent";
signal?: AbortSignal;
}
@@ -106,7 +111,7 @@ function createUtf8Decoder(): (chunk: Uint8Array) => string {
}
/** System prompt + optional tools section + today's date line. */
-function systemPrompt(): string {
+function systemPrompt(workflowId?: string, firecrawlConfirmed = false): string {
const today = new Date().toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
@@ -114,7 +119,7 @@ function systemPrompt(): string {
day: "numeric",
});
return (
- SYSTEM_PROMPT + (toolsAvailable() ? TOOLS_PROMPT_SECTION : "") + `\n\nToday is ${today}.`
+ SYSTEM_PROMPT + enabledPromptSections({ workflowId, firecrawlConfirmed }) + workflowPrompt(workflowId) + `\n\nToday is ${today}.`
);
}
@@ -134,6 +139,9 @@ async function streamRound(
convo: ChatMessage[],
includeTools: boolean,
onDelta: (text: string) => void,
+ workflowId?: string,
+ firecrawlConfirmed = false,
+ firecrawlOperation?: "search" | "scrape" | "agent",
signal?: AbortSignal,
): Promise {
const apiKey = cerebrasKey.get();
@@ -147,8 +155,8 @@ async function streamRound(
},
body: JSON.stringify({
model: GENOS_MODEL,
- messages: [{ role: "system", content: systemPrompt() }, ...convo],
- ...(includeTools ? { tools: TOOL_DEFS } : {}),
+ messages: [{ role: "system", content: systemPrompt(workflowId, firecrawlConfirmed) }, ...convo],
+ ...(includeTools ? { tools: enabledToolDefinitions({ workflowId, firecrawlConfirmed, firecrawlOperation }) } : {}),
stream: true,
temperature: 0.8,
max_completion_tokens: 3072,
@@ -247,14 +255,24 @@ async function streamRound(
}
export async function streamScreen(messages: ChatMessage[], handlers: StreamHandlers) {
- const { onDelta, onDone, onError, onToolRound, signal } = handlers;
+ const { onDelta, onDone, onError, onToolRound, onToolProgress, workflowId, firecrawlConfirmed, firecrawlOperation, signal } = handlers;
const convo: ChatMessage[] = [...messages];
+ let firecrawlCallStarted = false;
try {
for (let round = 0; ; round++) {
// Past the round budget, stop offering tools - forces a screen.
- const includeTools = toolsAvailable() && round < MAX_TOOL_ROUNDS;
- const result = await streamRound(convo, includeTools, onDelta, signal);
+ const context = { workflowId, firecrawlConfirmed, firecrawlOperation };
+ const includeTools = toolsAvailable(context) && round < MAX_TOOL_ROUNDS;
+ const result = await streamRound(
+ convo,
+ includeTools,
+ onDelta,
+ workflowId,
+ firecrawlConfirmed,
+ firecrawlOperation,
+ signal,
+ );
if (result.finish !== "tool_calls") {
onDone(result.info);
@@ -280,7 +298,17 @@ export async function streamScreen(messages: ChatMessage[], handlers: StreamHand
content: result.content || null,
tool_calls: result.toolCalls,
});
- const outputs = await Promise.all(calls.map((c) => executeTool(c.name, c.args, signal)));
+ // Execute sequentially. Agent calls are paid and must never be duplicated
+ // by parallel/speculative dispatch of the same round.
+ const outputs: string[] = [];
+ for (const call of calls) {
+ if (isFirecrawlTool(call.name) && firecrawlCallStarted) {
+ outputs.push("ERROR: this workflow already started its one confirmed Firecrawl operation");
+ continue;
+ }
+ if (isFirecrawlTool(call.name)) firecrawlCallStarted = true;
+ outputs.push(await executeTool(call.name, call.args, signal, onToolProgress, context));
+ }
if (signal?.aborted) return;
result.toolCalls.forEach((tc, i) => {
convo.push({ role: "tool", tool_call_id: tc.id, content: outputs[i] });
diff --git a/src/genos/tools/firecrawl.ts b/src/genos/tools/firecrawl.ts
new file mode 100644
index 0000000..58ab3eb
--- /dev/null
+++ b/src/genos/tools/firecrawl.ts
@@ -0,0 +1,410 @@
+import { fetch as expoFetch } from "expo/fetch";
+import { firecrawlKey } from "../providers/firecrawl/key";
+import {
+ agentPolicy,
+ trustedAgentSchema,
+ type FirecrawlWorkflowId,
+ type WorkflowDepth,
+} from "../workflows";
+
+const BASE_URL = "https://api.firecrawl.dev/v2";
+const REQUEST_TIMEOUT_MS = 60_000;
+const POLL_INTERVAL_MS = 3_000;
+const MAX_QUERY = 500;
+const MAX_PROMPT = 10_000;
+const MAX_URLS = 10;
+const MAX_RESULT_CHARS = 24_000;
+export const MAX_SEARCH_RESULTS = 10;
+
+/** Stable for the app session so screen retries resume instead of re-spending. */
+const agentJobs = new Map>();
+
+export type FirecrawlProgress = {
+ state: "starting" | "processing";
+ elapsedMs: number;
+ jobId?: string;
+};
+
+export interface FirecrawlAgentStatus {
+ success: boolean;
+ status: "processing" | "completed" | "failed" | "cancelled";
+ data?: unknown;
+ error?: string;
+ creditsUsed?: number;
+ expiresAt?: string;
+}
+
+function validUrl(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ try {
+ const url = new URL(value.trim());
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
+ } catch {
+ return null;
+ }
+}
+
+function boundedUrls(value: unknown): string[] | null {
+ if (value === undefined) return [];
+ if (!Array.isArray(value) || value.length > MAX_URLS) return null;
+ const urls = value.map(validUrl);
+ return urls.every((url): url is string => !!url) ? urls : null;
+}
+
+function requestSignal(parent: AbortSignal | undefined, timeoutMs: number) {
+ const controller = new AbortController();
+ const abort = () => controller.abort();
+ if (parent?.aborted) controller.abort();
+ else parent?.addEventListener("abort", abort, { once: true });
+ const timer = setTimeout(abort, timeoutMs);
+ return {
+ signal: controller.signal,
+ cleanup: () => {
+ clearTimeout(timer);
+ parent?.removeEventListener("abort", abort);
+ },
+ };
+}
+
+async function firecrawlRequest(
+ path: string,
+ init: { method: "GET" | "POST" | "DELETE"; body?: Record },
+ signal?: AbortSignal,
+ timeoutMs = REQUEST_TIMEOUT_MS,
+): Promise {
+ const key = firecrawlKey.get();
+ if (!key) throw new Error("Firecrawl key required");
+ const bounded = requestSignal(signal, timeoutMs);
+ try {
+ const response = await expoFetch(`${BASE_URL}${path}`, {
+ method: init.method,
+ headers: {
+ Authorization: `Bearer ${key}`,
+ "Content-Type": "application/json",
+ },
+ ...(init.body ? { body: JSON.stringify(init.body) } : {}),
+ signal: bounded.signal,
+ });
+ if (response.status === 401 || response.status === 403) {
+ firecrawlKey.markRejected(key);
+ throw new Error("Firecrawl rejected the API key - enter a valid key");
+ }
+ if (!response.ok) {
+ const detail = (await response.text().catch(() => "")).slice(0, 500);
+ if (response.status === 429) {
+ throw new Error("Firecrawl rate or concurrency limit reached - wait and try again");
+ }
+ if (response.status === 402 || /credit|payment/i.test(detail)) {
+ throw new Error("Firecrawl credit limit reached - review the account budget");
+ }
+ throw new Error(detail || `Firecrawl HTTP ${response.status}`);
+ }
+ return response.json();
+ } finally {
+ bounded.cleanup();
+ }
+}
+
+function truncate(value: string): string {
+ return value.length <= MAX_RESULT_CHARS
+ ? value
+ : `${value.slice(0, MAX_RESULT_CHARS)}\n[Result truncated at ${MAX_RESULT_CHARS} characters]`;
+}
+
+function boundedResult(data: unknown, metadata: Record): string {
+ const complete = JSON.stringify({ data, ...metadata });
+ if (complete.length <= MAX_RESULT_CHARS) return complete;
+ const suffix = JSON.stringify({ ...metadata, truncated: true });
+ const budget = Math.max(0, MAX_RESULT_CHARS - suffix.length - 40);
+ return JSON.stringify({
+ data: JSON.stringify(data).slice(0, budget),
+ ...metadata,
+ truncated: true,
+ });
+}
+
+function collectSources(value: unknown, out = new Set()): string[] {
+ if (typeof value === "string") {
+ const url = validUrl(value);
+ if (url) out.add(url);
+ } else if (Array.isArray(value)) {
+ value.forEach((item) => collectSources(item, out));
+ } else if (value && typeof value === "object") {
+ Object.values(value as Record).forEach((item) => collectSources(item, out));
+ }
+ return [...out].slice(0, 100);
+}
+
+export async function firecrawlSearch(
+ args: Record,
+ signal?: AbortSignal,
+): Promise {
+ const query = typeof args.query === "string" ? args.query.trim() : "";
+ if (!query || query.length > MAX_QUERY) return `ERROR: firecrawl_search requires a query up to ${MAX_QUERY} characters`;
+ const requested = Number(args.limit ?? 5);
+ if (!Number.isFinite(requested) || requested < 1) return "ERROR: firecrawl_search limit must be positive";
+ const limit = Math.min(MAX_SEARCH_RESULTS, Math.floor(requested));
+ const includeMarkdown = args.format === "markdown";
+ try {
+ const json = (await firecrawlRequest(
+ "/search",
+ {
+ method: "POST",
+ body: {
+ query,
+ limit,
+ sources: ["web"],
+ ...(includeMarkdown ? { scrapeOptions: { formats: [{ type: "markdown" }] } } : {}),
+ },
+ },
+ signal,
+ )) as {
+ data?: { web?: Array<{ title?: string; description?: string; url?: string; markdown?: string; metadata?: { sourceURL?: string } }> };
+ creditsUsed?: number;
+ warning?: string;
+ };
+ const results = (json.data?.web ?? []).slice(0, limit).map((result) => ({
+ title: result.title ?? "",
+ url: validUrl(result.url) ?? validUrl(result.metadata?.sourceURL) ?? "",
+ description: result.description ?? "",
+ ...(includeMarkdown ? { markdown: (result.markdown ?? "").slice(0, 4_000) } : {}),
+ }));
+ return truncate(JSON.stringify({ query, results, creditsUsed: json.creditsUsed, warning: json.warning ?? "", sources: results.map((r) => r.url).filter(Boolean) }));
+ } catch (error) {
+ return `ERROR: firecrawl search failed (${error instanceof Error ? error.message : String(error)})`;
+ }
+}
+
+export async function firecrawlScrape(
+ args: Record,
+ workflowId: string | undefined,
+ signal?: AbortSignal,
+): Promise {
+ const url = validUrl(args.url);
+ if (!url) return "ERROR: firecrawl_scrape requires one valid HTTP(S) URL";
+ const format = args.format === "json" ? "json" : "markdown";
+ const schema = format === "json" ? trustedAgentSchema(workflowId) : null;
+ if (format === "json" && !schema) return "ERROR: JSON scrape requires a trusted Firecrawl workflow";
+ try {
+ const json = (await firecrawlRequest(
+ "/scrape",
+ {
+ method: "POST",
+ body: {
+ url,
+ formats:
+ format === "json"
+ ? [{ type: "json", schema }]
+ : ["markdown"],
+ onlyMainContent: true,
+ timeout: REQUEST_TIMEOUT_MS,
+ },
+ },
+ signal,
+ )) as { data?: { markdown?: string; json?: unknown; metadata?: { sourceURL?: string; url?: string } }; creditsUsed?: number };
+ const source = validUrl(json.data?.metadata?.sourceURL) ?? validUrl(json.data?.metadata?.url) ?? url;
+ const data = format === "json" ? json.data?.json ?? {} : json.data?.markdown ?? "";
+ return boundedResult(data, { creditsUsed: json.creditsUsed, sources: [source] });
+ } catch (error) {
+ return `ERROR: firecrawl scrape failed (${error instanceof Error ? error.message : String(error)})`;
+ }
+}
+
+async function startFirecrawlAgent(
+ args: {
+ prompt: string;
+ urls?: string[];
+ schema: Record;
+ maxCredits: number;
+ model?: "spark-1-mini" | "spark-1-pro";
+ },
+ signal?: AbortSignal,
+): Promise {
+ if (!Number.isFinite(args.maxCredits) || args.maxCredits <= 0) {
+ throw new Error("Agent requires an explicit positive maxCredits");
+ }
+ const json = (await firecrawlRequest(
+ "/agent",
+ {
+ method: "POST",
+ body: {
+ prompt: args.prompt,
+ ...(args.urls?.length ? { urls: args.urls, strictConstrainToURLs: true } : {}),
+ schema: args.schema,
+ maxCredits: args.maxCredits,
+ model: args.model ?? "spark-1-mini",
+ },
+ },
+ signal,
+ )) as { success?: boolean; id?: string };
+ if (!json.success || !json.id) throw new Error("Firecrawl did not return an Agent job ID");
+ return json.id;
+}
+
+export async function getFirecrawlAgentStatus(jobId: string, signal?: AbortSignal): Promise {
+ if (!/^[a-zA-Z0-9-]{8,100}$/.test(jobId)) throw new Error("Invalid Firecrawl Agent job ID");
+ return (await firecrawlRequest(`/agent/${encodeURIComponent(jobId)}`, { method: "GET" }, signal)) as FirecrawlAgentStatus;
+}
+
+export async function cancelFirecrawlAgent(jobId: string): Promise {
+ await firecrawlRequest(`/agent/${encodeURIComponent(jobId)}`, { method: "DELETE" }, undefined, 10_000);
+}
+
+function pollDelay(signal?: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal?.aborted) return reject(new Error("aborted"));
+ const onAbort = () => {
+ clearTimeout(timer);
+ reject(new Error("aborted"));
+ };
+ const timer = setTimeout(() => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve();
+ }, POLL_INTERVAL_MS);
+ signal?.addEventListener("abort", onAbort, { once: true });
+ });
+}
+
+export async function pollFirecrawlAgent(
+ jobId: string,
+ timeoutMs: number,
+ signal?: AbortSignal,
+ progress?: (state: FirecrawlProgress) => void,
+): Promise {
+ const startedAt = Date.now();
+ for (;;) {
+ if (signal?.aborted) throw new Error(`Firecrawl Agent cancelled locally (job ${jobId})`);
+ const elapsedMs = Date.now() - startedAt;
+ if (elapsedMs >= timeoutMs) throw new Error(`Firecrawl Agent timed out; resume status for job ${jobId}`);
+ progress?.({ state: "processing", elapsedMs, jobId });
+ const status = await getFirecrawlAgentStatus(jobId, signal);
+ if (status.status !== "processing") return status;
+ await pollDelay(signal);
+ }
+}
+
+export async function firecrawlAgent(
+ args: Record,
+ workflowId: string | undefined,
+ signal?: AbortSignal,
+ progress?: (state: FirecrawlProgress) => void,
+): Promise {
+ const depth: WorkflowDepth =
+ args.depth === "thorough" || args.depth === "exhaustive" ? args.depth : "quick";
+ const policy = agentPolicy(workflowId, depth);
+ if (!policy) return "ERROR: firecrawl_agent requires a trusted Firecrawl workflow";
+ if (args.confirmCost !== true) {
+ return "ERROR: explicit Agent cost/depth confirmation is required";
+ }
+ const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
+ if (!prompt || prompt.length > MAX_PROMPT) return `ERROR: firecrawl_agent requires a prompt up to ${MAX_PROMPT} characters`;
+ const urls = boundedUrls(args.urls);
+ if (!urls) return `ERROR: firecrawl_agent accepts at most ${MAX_URLS} valid HTTP(S) URLs`;
+ const model = args.model === "spark-1-pro" ? "spark-1-pro" : "spark-1-mini";
+ if (model === "spark-1-pro" && args.highAccuracy !== true) return "ERROR: spark-1-pro requires an explicit high-accuracy choice";
+
+ let jobId = typeof args.jobId === "string" ? args.jobId : undefined;
+ try {
+ if (!jobId) {
+ progress?.({ state: "starting", elapsedMs: 0 });
+ const dedupeKey = JSON.stringify([workflowId, depth, model, prompt, urls]);
+ let start = agentJobs.get(dedupeKey);
+ if (!start) {
+ start = startFirecrawlAgent(
+ {
+ prompt: `${policy.contract.instructions}\n\nUser task: ${prompt}`,
+ urls,
+ schema: trustedAgentSchema(workflowId)!,
+ maxCredits: policy.maxCredits,
+ model,
+ },
+ signal,
+ );
+ agentJobs.set(dedupeKey, start);
+ start.catch(() => {
+ if (agentJobs.get(dedupeKey) === start) agentJobs.delete(dedupeKey);
+ });
+ }
+ jobId = await start;
+ }
+ const status = await pollFirecrawlAgent(jobId, policy.timeoutMs, signal, progress);
+ if (status.status === "failed") return `ERROR: Firecrawl Agent failed for job ${jobId} (${status.error ?? "unknown error"})`;
+ if (status.status === "cancelled") return `ERROR: Firecrawl Agent job ${jobId} was cancelled`;
+ const sources = collectSources(status.data);
+ return boundedResult(status.data ?? {}, { jobId, creditsUsed: status.creditsUsed ?? 0, sources });
+ } catch (error) {
+ if (signal?.aborted && jobId) {
+ await cancelFirecrawlAgent(jobId).catch(() => {});
+ return `ERROR: Firecrawl Agent cancelled locally (job ${jobId})`;
+ }
+ return `ERROR: ${error instanceof Error ? error.message : String(error)}`;
+ }
+}
+
+export const FIRECRAWL_TOOL_DEFS = [
+ {
+ type: "function" as const,
+ function: {
+ name: "firecrawl_search",
+ description: "Search the live web with a bounded result count and retained source URLs.",
+ parameters: {
+ type: "object",
+ properties: {
+ query: { type: "string" },
+ limit: { type: "integer", minimum: 1, maximum: MAX_SEARCH_RESULTS },
+ format: { type: "string", enum: ["summary", "markdown"] },
+ },
+ required: ["query"],
+ },
+ },
+ },
+ {
+ type: "function" as const,
+ function: {
+ name: "firecrawl_scrape",
+ description: "Scrape one HTTP(S) URL into bounded markdown or trusted structured output.",
+ parameters: {
+ type: "object",
+ properties: { url: { type: "string" }, format: { type: "string", enum: ["markdown", "json"] } },
+ required: ["url"],
+ },
+ },
+ },
+ {
+ type: "function" as const,
+ function: {
+ name: "firecrawl_agent",
+ description: "Run or resume a bounded asynchronous Firecrawl Agent job under the active trusted workflow policy.",
+ parameters: {
+ type: "object",
+ properties: {
+ prompt: { type: "string" },
+ urls: { type: "array", maxItems: MAX_URLS, items: { type: "string" } },
+ depth: { type: "string", enum: ["quick", "thorough", "exhaustive"] },
+ model: { type: "string", enum: ["spark-1-mini", "spark-1-pro"] },
+ highAccuracy: { type: "boolean" },
+ confirmCost: { type: "boolean" },
+ jobId: { type: "string", description: "Existing job ID to resume without creating another paid job" },
+ },
+ required: ["prompt", "depth", "confirmCost"],
+ },
+ },
+ },
+];
+
+export function isFirecrawlTool(name: string): boolean {
+ return name === "firecrawl_search" || name === "firecrawl_scrape" || name === "firecrawl_agent";
+}
+
+export async function executeFirecrawlTool(
+ name: string,
+ args: Record,
+ workflowId: FirecrawlWorkflowId,
+ signal?: AbortSignal,
+ progress?: (state: FirecrawlProgress) => void,
+): Promise {
+ if (name === "firecrawl_search") return firecrawlSearch(args, signal);
+ if (name === "firecrawl_scrape") return firecrawlScrape(args, workflowId, signal);
+ if (name === "firecrawl_agent") return firecrawlAgent(args, workflowId, signal, progress);
+ return `ERROR: unknown Firecrawl tool "${name}"`;
+}
diff --git a/src/genos/tools/index.ts b/src/genos/tools/index.ts
new file mode 100644
index 0000000..5c8d03b
--- /dev/null
+++ b/src/genos/tools/index.ts
@@ -0,0 +1,87 @@
+import { EXA_API_KEY } from "../../config";
+import { firecrawlKey } from "../providers/firecrawl/key";
+import { isFirecrawlWorkflow } from "../workflows";
+import {
+ executeFirecrawlTool,
+ FIRECRAWL_TOOL_DEFS,
+ isFirecrawlTool,
+ type FirecrawlProgress,
+} from "./firecrawl";
+export { isFirecrawlTool } from "./firecrawl";
+import {
+ executeTool as executeSearchTool,
+ TOOL_DEFS as SEARCH_TOOL_DEFS,
+ TOOLS_PROMPT_SECTION as SEARCH_PROMPT_SECTION,
+} from "./search";
+
+export interface ToolContext {
+ workflowId?: string;
+ firecrawlConfirmed?: boolean;
+ firecrawlOperation?: "search" | "scrape" | "agent";
+}
+
+export interface ProviderAvailability {
+ exa: boolean;
+ firecrawl: boolean;
+}
+
+export function providerAvailability(context: ToolContext = {}): ProviderAvailability {
+ return {
+ exa: !!EXA_API_KEY,
+ firecrawl:
+ !!firecrawlKey.get() &&
+ context.firecrawlConfirmed === true &&
+ isFirecrawlWorkflow(context.workflowId),
+ };
+}
+
+const FIRECRAWL_PROMPT_SECTION = `
+
+## Firecrawl tools
+Use only the tools enabled for the active trusted workflow. Prefer search for discovery and scrape for a known URL. Agent is asynchronous and credit-bearing: use it only when autonomous multi-page work is required, never start duplicate jobs, and resume an existing jobId after timeout. Preserve source URLs and blank unavailable fields.`;
+
+export function definitionsForProviders(available: ProviderAvailability) {
+ return [
+ ...(available.exa ? SEARCH_TOOL_DEFS : []),
+ ...(available.firecrawl ? FIRECRAWL_TOOL_DEFS : []),
+ ];
+}
+
+export function enabledPromptSections(context: ToolContext = {}): string {
+ const available = providerAvailability(context);
+ return `${available.exa ? SEARCH_PROMPT_SECTION : ""}${available.firecrawl ? FIRECRAWL_PROMPT_SECTION : ""}`;
+}
+
+export function enabledToolDefinitions(context: ToolContext = {}) {
+ const definitions = definitionsForProviders(providerAvailability(context));
+ if (!context.firecrawlOperation) return definitions;
+ return definitions.filter(
+ (definition) =>
+ !isFirecrawlTool(definition.function.name) ||
+ definition.function.name === `firecrawl_${context.firecrawlOperation}`,
+ );
+}
+
+export function toolsAvailable(context: ToolContext = {}): boolean {
+ return enabledToolDefinitions(context).length > 0;
+}
+
+export async function executeTool(
+ name: string,
+ args: Record,
+ signal?: AbortSignal,
+ progress?: (state: FirecrawlProgress) => void,
+ context: ToolContext = {},
+): Promise {
+ if (name === "web_search") return executeSearchTool(name, args, signal);
+ if (isFirecrawlTool(name)) {
+ if (!firecrawlKey.get()) return "ERROR: Firecrawl key required";
+ if (context.firecrawlConfirmed !== true) return "ERROR: explicit Firecrawl setup and budget confirmation required";
+ if (!isFirecrawlWorkflow(context.workflowId)) return "ERROR: Firecrawl tool requires a trusted workflow";
+ if (context.firecrawlOperation && name !== `firecrawl_${context.firecrawlOperation}`) {
+ return `ERROR: this workflow confirmed the ${context.firecrawlOperation} operation, not ${name}`;
+ }
+ return executeFirecrawlTool(name, args, context.workflowId, signal, progress);
+ }
+ return `ERROR: unknown tool "${name}"`;
+}
diff --git a/src/genos/typography.tsx b/src/genos/typography.tsx
new file mode 100644
index 0000000..b0017a4
--- /dev/null
+++ b/src/genos/typography.tsx
@@ -0,0 +1,134 @@
+import React, { createContext, useContext } from "react";
+import {
+ Platform,
+ StyleSheet,
+ Text as NativeText,
+ TextInput as NativeTextInput,
+ type TextInputProps,
+ type TextProps,
+ type TextStyle,
+} from "react-native";
+
+const FONT_FAMILIES = {
+ light: "Inter_300Light",
+ regular: "Inter_400Regular",
+ medium: "Inter_500Medium",
+ semibold: "Inter_600SemiBold",
+ bold: "Inter_700Bold",
+ extrabold: "Inter_800ExtraBold",
+} as const;
+
+const SYSTEM_FONT = Platform.select({
+ ios: "System",
+ android: "sans-serif",
+ default: "system-ui",
+});
+
+const TypographyReadyContext = createContext(false);
+
+export function TypographyProvider({
+ loaded,
+ children,
+}: {
+ loaded: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function familyForStyle(style: TextProps["style"] | TextInputProps["style"], loaded: boolean) {
+ const flattened = StyleSheet.flatten(style) as TextStyle | undefined;
+ if (flattened?.fontFamily) return flattened.fontFamily;
+ if (!loaded) return SYSTEM_FONT;
+
+ const numericWeight = Number.parseInt(String(flattened?.fontWeight ?? "400"), 10);
+ if (numericWeight >= 800) return FONT_FAMILIES.extrabold;
+ if (numericWeight >= 700) return FONT_FAMILIES.bold;
+ if (numericWeight >= 600) return FONT_FAMILIES.semibold;
+ if (numericWeight >= 500) return FONT_FAMILIES.medium;
+ if (numericWeight <= 300) return FONT_FAMILIES.light;
+ return FONT_FAMILIES.regular;
+}
+
+export function Text({ style, ...props }: TextProps) {
+ const loaded = useContext(TypographyReadyContext);
+ const fontFamily = familyForStyle(style, loaded);
+ return ;
+}
+
+export const TextInput = React.forwardRef(function TextInput(
+ { style, ...props },
+ ref,
+) {
+ const loaded = useContext(TypographyReadyContext);
+ const fontFamily = familyForStyle(style, loaded);
+ return ;
+});
+
+/** Linear-inspired hierarchy using Inter, its closest public substitute. */
+export const linearType = {
+ displayLarge: {
+ fontSize: 56,
+ lineHeight: 62,
+ fontWeight: "600",
+ letterSpacing: -1.8,
+ },
+ headline: {
+ fontSize: 28,
+ lineHeight: 34,
+ fontWeight: "600",
+ letterSpacing: -0.6,
+ },
+ cardTitle: {
+ fontSize: 22,
+ lineHeight: 28,
+ fontWeight: "500",
+ letterSpacing: -0.4,
+ },
+ subhead: {
+ fontSize: 20,
+ lineHeight: 28,
+ fontWeight: "400",
+ letterSpacing: -0.2,
+ },
+ bodyLarge: {
+ fontSize: 18,
+ lineHeight: 27,
+ fontWeight: "400",
+ letterSpacing: -0.1,
+ },
+ body: {
+ fontSize: 16,
+ lineHeight: 24,
+ fontWeight: "400",
+ letterSpacing: -0.05,
+ },
+ bodySmall: {
+ fontSize: 14,
+ lineHeight: 21,
+ fontWeight: "400",
+ letterSpacing: 0,
+ },
+ caption: {
+ fontSize: 12,
+ lineHeight: 17,
+ fontWeight: "400",
+ letterSpacing: 0,
+ },
+ button: {
+ fontSize: 14,
+ lineHeight: 17,
+ fontWeight: "500",
+ letterSpacing: 0,
+ },
+ eyebrow: {
+ fontSize: 13,
+ lineHeight: 17,
+ fontWeight: "500",
+ letterSpacing: 0.4,
+ },
+} satisfies Record;
diff --git a/src/genos/ui/ProviderIcon.tsx b/src/genos/ui/ProviderIcon.tsx
new file mode 100644
index 0000000..c2a6737
--- /dev/null
+++ b/src/genos/ui/ProviderIcon.tsx
@@ -0,0 +1,56 @@
+import { Globe, Link } from "phosphor-react-native";
+import React, { useEffect, useState } from "react";
+import { Image, View } from "react-native";
+import { getProvider, raycastFaviconUrl } from "../providers";
+
+export function ProviderIcon({
+ providerId,
+ size,
+ cornerRadius = Math.round(size * 0.22),
+}: {
+ providerId: string;
+ size: number;
+ cornerRadius?: number;
+}) {
+ const provider = getProvider(providerId);
+ const source = provider ? raycastFaviconUrl(provider.domain, size) : null;
+ const [imageFailed, setImageFailed] = useState(false);
+
+ useEffect(() => {
+ setImageFailed(false);
+ }, [providerId]);
+
+ const FallbackIcon = provider?.fallbackGlyph === "globe" ? Globe : Link;
+ const label = provider ? `${provider.name} provider` : "Provider";
+
+ return (
+
+
+ {source && !imageFailed && (
+ setImageFailed(true)}
+ style={{
+ position: "absolute",
+ width: size,
+ height: size,
+ borderRadius: cornerRadius,
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/genos/ui/cupertino/components.tsx b/src/genos/ui/cupertino/components.tsx
index 19f3cfd..452014d 100644
--- a/src/genos/ui/cupertino/components.tsx
+++ b/src/genos/ui/cupertino/components.tsx
@@ -5,7 +5,8 @@
import { useTriggerAction } from "@openuidev/react-lang";
import { LinearGradient } from "expo-linear-gradient";
import React, { useState } from "react";
-import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
+import { Pressable, ScrollView, StyleSheet, View } from "react-native";
+import { Text, linearType } from "../../typography";
import { useTap } from "../shared/actions";
import { createImg } from "../shared/media";
import type {
@@ -65,9 +66,7 @@ export const CardHeader: Renderer = ({ props }) => {
= ({ props }) => {
)}
@@ -91,15 +87,12 @@ export const CardHeader: Renderer = ({ props }) => {
};
const TEXT_STYLES = {
- small: { fontSize: 12.5, lineHeight: 18 },
- default: { fontSize: 15, lineHeight: 22 },
- large: { fontSize: 17, lineHeight: 24 },
- "small-heavy": { fontSize: 13, lineHeight: 19, fontWeight: "600" as const },
+ small: linearType.caption,
+ default: linearType.body,
+ large: linearType.bodyLarge,
+ "small-heavy": linearType.button,
"large-heavy": {
- fontSize: 20,
- lineHeight: 26,
- fontWeight: "700" as const,
- letterSpacing: -0.3,
+ ...linearType.cardTitle,
marginBottom: -6,
},
};
diff --git a/src/genos/ui/cupertino/forms.tsx b/src/genos/ui/cupertino/forms.tsx
index 7465c27..e5383de 100644
--- a/src/genos/ui/cupertino/forms.tsx
+++ b/src/genos/ui/cupertino/forms.tsx
@@ -7,7 +7,8 @@ import { FormNameContext, useFormName, useTriggerAction } from "@openuidev/react
import type { ActionPlan } from "@openuidev/react-lang";
import RNSlider from "@react-native-community/slider";
import React, { useState } from "react";
-import { Modal, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
+import { Modal, Pressable, StyleSheet, View } from "react-native";
+import { Text, TextInput } from "../../typography";
import { readSelectItems, textInputBehaviorProps, useFieldState } from "../shared/forms";
import type {
ButtonProps,
diff --git a/src/genos/ui/material/components.tsx b/src/genos/ui/material/components.tsx
index 3b7343b..b4536e3 100644
--- a/src/genos/ui/material/components.tsx
+++ b/src/genos/ui/material/components.tsx
@@ -5,7 +5,8 @@
import { useTriggerAction } from "@openuidev/react-lang";
import { LinearGradient } from "expo-linear-gradient";
import React, { useState } from "react";
-import { Pressable, ScrollView, Switch, Text, View } from "react-native";
+import { Pressable, ScrollView, Switch, View } from "react-native";
+import { Text, linearType } from "../../typography";
import { useTap } from "../shared/actions";
import { createImg } from "../shared/media";
import type {
@@ -63,16 +64,14 @@ export const CardHeader: Renderer = ({ props }) => {
{props.subtitle}
)}
-
+
{props.title}
@@ -80,14 +79,12 @@ export const CardHeader: Renderer = ({ props }) => {
};
const TEXT_STYLES = {
- small: { fontSize: 12.5, lineHeight: 18 },
- default: { fontSize: 15, lineHeight: 22 },
- large: { fontSize: 17, lineHeight: 24 },
- "small-heavy": { fontSize: 13, lineHeight: 19, fontWeight: "500" as const },
+ small: linearType.caption,
+ default: linearType.body,
+ large: linearType.bodyLarge,
+ "small-heavy": linearType.button,
"large-heavy": {
- fontSize: 20,
- lineHeight: 26,
- fontWeight: "500" as const,
+ ...linearType.cardTitle,
marginBottom: -6,
},
};
diff --git a/src/genos/ui/material/forms.tsx b/src/genos/ui/material/forms.tsx
index 540a304..be6fd89 100644
--- a/src/genos/ui/material/forms.tsx
+++ b/src/genos/ui/material/forms.tsx
@@ -3,7 +3,8 @@ import { FormNameContext, useFormName, useTriggerAction } from "@openuidev/react
import type { ActionPlan } from "@openuidev/react-lang";
import RNSlider from "@react-native-community/slider";
import React, { useState } from "react";
-import { Modal, Pressable, Text, TextInput, View } from "react-native";
+import { Modal, Pressable, View } from "react-native";
+import { Text, TextInput } from "../../typography";
import { readSelectItems, textInputBehaviorProps, useFieldState } from "../shared/forms";
import type {
ButtonProps,
diff --git a/src/genos/ui/shared/charts.tsx b/src/genos/ui/shared/charts.tsx
index b720d40..31252e9 100644
--- a/src/genos/ui/shared/charts.tsx
+++ b/src/genos/ui/shared/charts.tsx
@@ -4,7 +4,8 @@
* its own colors. Signatures/schemas live in ../contract.tsx.
*/
import React, { useState } from "react";
-import { Text as RNText, View } from "react-native";
+import { View } from "react-native";
+import { Text as RNText } from "../../typography";
import Svg, { Circle, G, Path, Rect, Text as SvgText } from "react-native-svg";
import type { CartesianChartProps, GenosRenderers, PieChartProps, Renderer } from "../contract";
diff --git a/src/genos/workflows.ts b/src/genos/workflows.ts
new file mode 100644
index 0000000..c8d8179
--- /dev/null
+++ b/src/genos/workflows.ts
@@ -0,0 +1,446 @@
+export const FIRECRAWL_WORKFLOW_IDS = [
+ "firecrawl-company-directories",
+ "firecrawl-competitive-intel",
+ "firecrawl-dashboard-reporting",
+ "firecrawl-deep-research",
+ "firecrawl-demo-walkthrough",
+ "firecrawl-knowledge-base",
+ "firecrawl-knowledge-ingest",
+ "firecrawl-lead-gen",
+ "firecrawl-lead-research",
+ "firecrawl-market-research",
+ "firecrawl-qa",
+ "firecrawl-research-papers",
+ "firecrawl-seo-audit",
+ "firecrawl-shop",
+ "firecrawl-website-design-clone",
+ "firecrawl-workflows",
+ "firecrawl",
+] as const;
+
+export type FirecrawlWorkflowId = (typeof FIRECRAWL_WORKFLOW_IDS)[number];
+export type WorkflowDepth = "quick" | "thorough" | "exhaustive";
+
+export interface WorkflowContract {
+ id: FirecrawlWorkflowId;
+ instructions: string;
+ resultFields: readonly string[];
+ maxCredits: number | Record;
+ timeoutMs: number | Record;
+ requiresAgentConfirmation?: boolean;
+}
+
+export type WorkflowFieldType = "text" | "url" | "number" | "select" | "checkbox";
+
+export interface WorkflowFieldDef {
+ id: string;
+ label: string;
+ type: WorkflowFieldType;
+ required?: boolean;
+ placeholder?: string;
+ hint?: string;
+ min?: number;
+ max?: number;
+ options?: readonly { label: string; value: string }[];
+ visibleWhen?: { fieldId: string; equals: string };
+}
+
+export interface WorkflowSetupDef {
+ workflowId: FirecrawlWorkflowId;
+ fields: readonly WorkflowFieldDef[];
+}
+
+export type WorkflowSetupValues = Record;
+
+const field = (
+ id: string,
+ label: string,
+ type: WorkflowFieldType,
+ options: Omit = {},
+): WorkflowFieldDef => ({ id, label, type, ...options });
+
+const depthField = (exhaustive = false): WorkflowFieldDef =>
+ field("depth", "Research depth", "select", {
+ required: true,
+ options: [
+ { label: "Quick", value: "quick" },
+ { label: "Thorough", value: "thorough" },
+ ...(exhaustive ? [{ label: "Exhaustive", value: "exhaustive" }] : []),
+ ],
+ });
+
+const confirmationField = field("confirmCredits", "Confirm the displayed maximum credit budget", "checkbox", {
+ required: true,
+ hint: "This confirmation is required before AppLess starts any Firecrawl request.",
+});
+
+const setup = (
+ workflowId: FirecrawlWorkflowId,
+ fields: readonly WorkflowFieldDef[],
+): WorkflowSetupDef => ({ workflowId, fields: [...fields, confirmationField] });
+
+const contract = (
+ id: FirecrawlWorkflowId,
+ instructions: string,
+ resultFields: readonly string[],
+ maxCredits: WorkflowContract["maxCredits"] = 150,
+ timeoutMs: WorkflowContract["timeoutMs"] = 180_000,
+ requiresAgentConfirmation = false,
+): WorkflowContract => ({ id, instructions, resultFields, maxCredits, timeoutMs, requiresAgentConfirmation });
+
+export const FIRECRAWL_WORKFLOWS: Record = {
+ "firecrawl-company-directories": contract(
+ "firecrawl-company-directories",
+ "Require target directory and row/page bound. Collect visible, legitimately accessible fields only; follow bounded pagination, report progress, dedupe records, and use blank values for unavailable fields.",
+ ["records", "pagination", "missingFields", "sources"],
+ ),
+ "firecrawl-competitive-intel": contract(
+ "firecrawl-competitive-intel",
+ "Require named competitors and scope. Prefer current pricing, product, feature, and changelog evidence; timestamp comparisons and preserve conflicting or missing claims.",
+ ["asOf", "comparisons", "conflicts", "missingFields", "sources"],
+ 250,
+ 300_000,
+ ),
+ "firecrawl-dashboard-reporting": contract(
+ "firecrawl-dashboard-reporting",
+ "Require an authorized dashboard/session boundary, metric definitions, and reporting period. Never capture credentials or include them in output; do not cross the authorized session boundary.",
+ ["period", "metricDefinitions", "metrics", "missingFields", "sources"],
+ ),
+ "firecrawl-deep-research": contract(
+ "firecrawl-deep-research",
+ "Require quick, thorough, or exhaustive depth. Prefer primary sources; return cited synthesis, risks, uncertainty, and open questions without invented claims.",
+ ["depth", "summary", "findings", "risks", "openQuestions", "sources"],
+ { quick: 100, thorough: 300, exhaustive: 750 },
+ { quick: 180_000, thorough: 480_000, exhaustive: 900_000 },
+ ),
+ "firecrawl-demo-walkthrough": contract(
+ "firecrawl-demo-walkthrough",
+ "Require a bounded product flow. Record observed UX evidence; do not submit, publish, purchase, delete, or make any state-changing action without explicit permission.",
+ ["steps", "observations", "limitations", "sources"],
+ ),
+ "firecrawl-knowledge-base": contract(
+ "firecrawl-knowledge-base",
+ "Require scoped source URLs and crawl boundary. Retain provenance, dedupe content, record update timestamps, and state login or access limitations.",
+ ["documents", "updatedAt", "duplicates", "limitations", "sources"],
+ ),
+ "firecrawl-knowledge-ingest": contract(
+ "firecrawl-knowledge-ingest",
+ "Require scoped sources and explicit ingest boundary. Normalize and dedupe entries while retaining URL provenance and update timestamps; report login limitations and do not upload elsewhere.",
+ ["entries", "updatedAt", "duplicates", "limitations", "sources"],
+ ),
+ "firecrawl-lead-gen": contract(
+ "firecrawl-lead-gen",
+ "Require audience, geography, and result limit. Use legitimately accessible fields only, dedupe companies and people, preserve data gaps, and never bypass access controls or guess contact details.",
+ ["leads", "missingFields", "methodology", "sources"],
+ ),
+ "firecrawl-lead-research": contract(
+ "firecrawl-lead-research",
+ "Require named person/company and research scope. Produce a concise sourced brief; separate verified facts from inferred pain points and leave unknown email, phone, funding, and roles blank.",
+ ["facts", "inferredPainPoints", "missingFields", "sources"],
+ ),
+ "firecrawl-market-research": contract(
+ "firecrawl-market-research",
+ "Require market, geography, period, and question. Prefer primary sources; date every metric and explain methodology, conflicts, uncertainty, and missing evidence.",
+ ["asOf", "metrics", "findings", "methodology", "uncertainty", "sources"],
+ 250,
+ 300_000,
+ ),
+ "firecrawl-qa": contract(
+ "firecrawl-qa",
+ "Require bounded target, test charter, and allowed actions. Return reproducible steps and observed evidence; never perform destructive or state-changing submissions.",
+ ["charter", "checks", "findings", "limitations", "sources"],
+ ),
+ "firecrawl-research-papers": contract(
+ "firecrawl-research-papers",
+ "Require topic and date/scope. Prefer primary paper or PDF links; capture metadata, methodology, results, and limitations, and never invent citations.",
+ ["papers", "methodology", "results", "limitations", "sources"],
+ ),
+ "firecrawl-seo-audit": contract(
+ "firecrawl-seo-audit",
+ "Require site and crawl boundary. Gather metadata and indexability evidence; return prioritized findings with representative page samples and explicit coverage gaps.",
+ ["boundary", "findings", "pageSamples", "missingFields", "sources"],
+ ),
+ "firecrawl-shop": contract(
+ "firecrawl-shop",
+ "Require product constraints, geography, and result limit. Capture current price and availability evidence, compare options, and never add to cart or purchase.",
+ ["constraints", "products", "comparisons", "missingFields", "sources"],
+ ),
+ "firecrawl-website-design-clone": contract(
+ "firecrawl-website-design-clone",
+ "Require target pages and scope. Record observed design tokens, components, and asset provenance; produce a DESIGN.md-shaped result without copying protected content wholesale.",
+ ["designMarkdown", "tokens", "components", "assetProvenance", "sources"],
+ ),
+ "firecrawl-workflows": contract(
+ "firecrawl-workflows",
+ "Act only as a chooser: identify the single concrete Firecrawl workflow that matches the request, collect its required inputs, then route to that contract. Never run an unbounded generic Agent.",
+ ["workflowId", "requiredInputs", "reason", "sources"],
+ 150,
+ 180_000,
+ true,
+ ),
+ firecrawl: contract(
+ "firecrawl",
+ "Require an explicit search, scrape, or Agent choice. Search and scrape are preferred; Agent requires an explicit cost/depth confirmation before it may start.",
+ ["operation", "data", "missingFields", "sources"],
+ 150,
+ 180_000,
+ true,
+ ),
+};
+
+const OUTPUT_OPTIONS = [
+ { label: "Structured list", value: "list" },
+ { label: "Table", value: "table" },
+] as const;
+
+/**
+ * Shell-owned setup contracts. These are deliberately application data, not
+ * local agent SKILL.md files, so packaged builds have the same deterministic
+ * required inputs and credit confirmation behavior.
+ */
+export const FIRECRAWL_WORKFLOW_SETUPS: Record = {
+ "firecrawl-company-directories": setup("firecrawl-company-directories", [
+ field("directory", "Directory URL or name", "text", { required: true, placeholder: "https://example.com/directory" }),
+ field("filters", "Optional filters", "text", { placeholder: "Region, category, company size" }),
+ field("resultCap", "Maximum results", "number", { required: true, min: 1, max: 100 }),
+ field("outputView", "Output view", "select", { required: true, options: OUTPUT_OPTIONS }),
+ depthField(),
+ ]),
+ "firecrawl-competitive-intel": setup("firecrawl-competitive-intel", [
+ field("competitors", "Competitors", "text", { required: true, placeholder: "Company A, Company B" }),
+ field("scope", "Comparison scope", "text", { required: true, placeholder: "Pricing, features, changelog" }),
+ depthField(),
+ ]),
+ "firecrawl-dashboard-reporting": setup("firecrawl-dashboard-reporting", [
+ field("dashboardUrl", "Authorized dashboard URL", "url", { required: true }),
+ field("metrics", "Metric definitions", "text", { required: true }),
+ field("period", "Reporting period", "text", { required: true, placeholder: "2026 Q2" }),
+ field("authorized", "I am authorized to access this dashboard", "checkbox", { required: true }),
+ depthField(),
+ ]),
+ "firecrawl-deep-research": setup("firecrawl-deep-research", [
+ field("topic", "Research topic", "text", { required: true }),
+ depthField(true),
+ field("confirmExhaustive", "Separately confirm the 750-credit exhaustive ceiling", "checkbox", {
+ required: true,
+ visibleWhen: { fieldId: "depth", equals: "exhaustive" },
+ }),
+ ]),
+ "firecrawl-demo-walkthrough": setup("firecrawl-demo-walkthrough", [
+ field("url", "Public product URL", "url", { required: true }),
+ field("flow", "Bounded flow to observe", "text", { required: true }),
+ depthField(),
+ ]),
+ "firecrawl-knowledge-base": setup("firecrawl-knowledge-base", [
+ field("sources", "Source URLs", "text", { required: true, placeholder: "One or more public URLs" }),
+ field("boundary", "Crawl boundary", "text", { required: true, placeholder: "Docs section and page cap" }),
+ field("pageCap", "Maximum pages", "number", { required: true, min: 1, max: 100 }),
+ depthField(),
+ ]),
+ "firecrawl-knowledge-ingest": setup("firecrawl-knowledge-ingest", [
+ field("sources", "Source URLs", "text", { required: true }),
+ field("boundary", "In-app ingest boundary", "text", { required: true }),
+ field("pageCap", "Maximum pages", "number", { required: true, min: 1, max: 100 }),
+ depthField(),
+ ]),
+ "firecrawl-lead-gen": setup("firecrawl-lead-gen", [
+ field("target", "Target audience and geography", "text", { required: true }),
+ field("sourceNote", "Source or authorization note", "text", { required: true }),
+ field("leadCap", "Maximum leads", "number", { required: true, min: 1, max: 100 }),
+ field("outputView", "Output view", "select", { required: true, options: OUTPUT_OPTIONS }),
+ depthField(),
+ ]),
+ "firecrawl-lead-research": setup("firecrawl-lead-research", [
+ field("company", "Company name or URL", "text", { required: true }),
+ field("person", "Optional person", "text"),
+ field("meetingContext", "Meeting context", "text", { required: true }),
+ depthField(),
+ ]),
+ "firecrawl-market-research": setup("firecrawl-market-research", [
+ field("market", "Market and geography", "text", { required: true }),
+ field("period", "Period", "text", { required: true }),
+ field("question", "Research question", "text", { required: true }),
+ depthField(),
+ ]),
+ "firecrawl-qa": setup("firecrawl-qa", [
+ field("targetUrl", "Public target URL", "url", { required: true }),
+ field("charter", "Bounded test charter", "text", { required: true }),
+ field("allowedActions", "Allowed read-only actions", "text", { required: true }),
+ depthField(),
+ ]),
+ "firecrawl-research-papers": setup("firecrawl-research-papers", [
+ field("topic", "Paper topic", "text", { required: true }),
+ field("scope", "Date or publication scope", "text", { required: true }),
+ field("paperCap", "Maximum papers", "number", { required: true, min: 1, max: 50 }),
+ depthField(),
+ ]),
+ "firecrawl-seo-audit": setup("firecrawl-seo-audit", [
+ field("siteUrl", "Site URL", "url", { required: true }),
+ field("boundary", "Crawl boundary", "text", { required: true }),
+ field("pageCap", "Maximum pages", "number", { required: true, min: 1, max: 100 }),
+ depthField(),
+ ]),
+ "firecrawl-shop": setup("firecrawl-shop", [
+ field("product", "Product and constraints", "text", { required: true }),
+ field("geography", "Shopping geography", "text", { required: true }),
+ field("resultCap", "Maximum options", "number", { required: true, min: 1, max: 50 }),
+ depthField(),
+ ]),
+ "firecrawl-website-design-clone": setup("firecrawl-website-design-clone", [
+ field("targetUrl", "Target page URL", "url", { required: true }),
+ field("scope", "Pages and design scope", "text", { required: true }),
+ field("respectRights", "I will respect asset and content rights", "checkbox", { required: true }),
+ depthField(),
+ ]),
+ "firecrawl-workflows": setup("firecrawl-workflows", [
+ field("goal", "What do you need?", "text", { required: true }),
+ field("workflowChoice", "Concrete workflow", "select", {
+ required: true,
+ options: FIRECRAWL_WORKFLOW_IDS.filter((id) => id !== "firecrawl" && id !== "firecrawl-workflows").map((id) => ({ label: id.replace(/^firecrawl-/, "").replace(/-/g, " "), value: id })),
+ }),
+ depthField(),
+ ]),
+ firecrawl: setup("firecrawl", [
+ field("operation", "Operation", "select", {
+ required: true,
+ options: [
+ { label: "Scrape one URL", value: "scrape" },
+ { label: "Search the web", value: "search" },
+ { label: "Agent research", value: "agent" },
+ ],
+ }),
+ field("url", "URL", "url", { required: true, visibleWhen: { fieldId: "operation", equals: "scrape" } }),
+ field("query", "Search query", "text", { required: true, visibleWhen: { fieldId: "operation", equals: "search" } }),
+ field("task", "Agent task", "text", { required: true, visibleWhen: { fieldId: "operation", equals: "agent" } }),
+ field("resultCap", "Maximum search results", "number", { required: true, min: 1, max: 10, visibleWhen: { fieldId: "operation", equals: "search" } }),
+ { ...depthField(true), visibleWhen: { fieldId: "operation", equals: "agent" } },
+ ]),
+};
+
+export function visibleWorkflowFields(
+ workflowId: FirecrawlWorkflowId,
+ values: WorkflowSetupValues,
+): readonly WorkflowFieldDef[] {
+ return FIRECRAWL_WORKFLOW_SETUPS[workflowId].fields.filter(
+ (candidate) => !candidate.visibleWhen || values[candidate.visibleWhen.fieldId] === candidate.visibleWhen.equals,
+ );
+}
+
+export function validateWorkflowSetup(
+ workflowId: FirecrawlWorkflowId,
+ values: WorkflowSetupValues,
+): Record {
+ const errors: Record = {};
+ for (const candidate of visibleWorkflowFields(workflowId, values)) {
+ const value = values[candidate.id];
+ if (candidate.required && (value === undefined || value === "" || value === false)) {
+ errors[candidate.id] = `${candidate.label} is required`;
+ continue;
+ }
+ if (candidate.type === "url" && typeof value === "string" && value) {
+ try {
+ const url = new URL(value);
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error();
+ } catch {
+ errors[candidate.id] = "Enter a valid HTTP(S) URL";
+ }
+ }
+ if (candidate.type === "number" && value !== undefined && value !== "") {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || (candidate.min !== undefined && numeric < candidate.min) || (candidate.max !== undefined && numeric > candidate.max)) {
+ errors[candidate.id] = `Enter a number from ${candidate.min ?? 0} to ${candidate.max ?? "the allowed maximum"}`;
+ }
+ }
+ }
+ return errors;
+}
+
+export function setupCreditBudget(
+ workflowId: FirecrawlWorkflowId,
+ values: WorkflowSetupValues,
+): number {
+ const depth = values.depth === "thorough" || values.depth === "exhaustive" ? values.depth : "quick";
+ return agentPolicy(workflowId, depth)?.maxCredits ?? 0;
+}
+
+export function isFirecrawlWorkflow(id: string | undefined): id is FirecrawlWorkflowId {
+ return !!id && Object.prototype.hasOwnProperty.call(FIRECRAWL_WORKFLOWS, id);
+}
+
+export function workflowPrompt(id: string | undefined): string {
+ if (!isFirecrawlWorkflow(id)) return "";
+ const c = FIRECRAWL_WORKFLOWS[id];
+ return `\n\n## Trusted Firecrawl workflow: ${id}\n${c.instructions}\nReturn only observed facts, retain source URLs, and leave unavailable structured fields blank. Never fabricate email, phone, funding, roles, company facts, or citations.`;
+}
+
+export function agentPolicy(
+ id: string | undefined,
+ depth: WorkflowDepth = "quick",
+): { maxCredits: number; timeoutMs: number; contract: WorkflowContract } | null {
+ if (!isFirecrawlWorkflow(id)) return null;
+ const c = FIRECRAWL_WORKFLOWS[id];
+ const maxCredits = typeof c.maxCredits === "number" ? c.maxCredits : c.maxCredits[depth];
+ const timeoutMs = typeof c.timeoutMs === "number" ? c.timeoutMs : c.timeoutMs[depth];
+ return { maxCredits, timeoutMs, contract: c };
+}
+
+export function trustedAgentSchema(id: string | undefined): Record | null {
+ if (!isFirecrawlWorkflow(id)) return null;
+ const fields = FIRECRAWL_WORKFLOWS[id].resultFields;
+ const scalar = {
+ anyOf: [
+ { type: "string", maxLength: 12_000 },
+ { type: "number" },
+ { type: "boolean" },
+ { type: "null" },
+ ],
+ };
+ const stringFields = new Set([
+ "asOf",
+ "period",
+ "updatedAt",
+ "summary",
+ "methodology",
+ "uncertainty",
+ "designMarkdown",
+ "boundary",
+ "charter",
+ "depth",
+ "workflowId",
+ "reason",
+ "operation",
+ ]);
+ const stringArrays = new Set([
+ "conflicts",
+ "missingFields",
+ "risks",
+ "openQuestions",
+ "duplicates",
+ "limitations",
+ "requiredInputs",
+ ]);
+ const objectFields = new Set(["metricDefinitions", "constraints", "data"]);
+ const fieldSchema = (field: string): Record => {
+ if (field === "sources") {
+ return { type: "array", maxItems: 100, items: { type: "string", format: "uri" } };
+ }
+ if (stringFields.has(field)) return { type: "string", maxLength: 12_000 };
+ if (stringArrays.has(field)) {
+ return { type: "array", maxItems: 100, items: { type: "string", maxLength: 2_000 } };
+ }
+ if (objectFields.has(field)) {
+ return { type: "object", maxProperties: 100, additionalProperties: scalar };
+ }
+ return {
+ type: "array",
+ maxItems: 100,
+ items: { type: "object", maxProperties: 50, additionalProperties: scalar },
+ };
+ };
+ return {
+ type: "object",
+ additionalProperties: false,
+ properties: Object.fromEntries(fields.map((field) => [field, fieldSchema(field)])),
+ required: [...fields],
+ };
+}