+
+
+
+ Selected tools
+ {selectedToolNames.length > 0 && (
+
+ {selectedToolNames.length} of {tools.length}
+
+ )}
-
+
-
- Select the tools you plan to use to see the required OAuth scopes.
-
+ {/* Tools Grid */}
-
- {tools.map((tool) => (
-
- ))}
+
+ {tools.map((tool) => {
+ const toolHasScopes = (tool.scopes?.length ?? 0) > 0;
+ const toolHasSecrets = (tool.secrets?.length ?? 0) > 0;
+ return (
+
+ );
+ })}
-
-
- Required scopes{" "}
- {selectedTools.size > 0 && (
-
- ({requiredScopes.length})
-
+ {/* Copy Actions */}
+ {selectedToolNames.length > 0 && (
+
+
+
+ {showAdvanced && requiredScopes.length > 0 && (
+
+ )}
+ {requiredSecrets.length > 0 && (
+
)}
+
+ )}
+
+ {/* Requirements Summary */}
+
+
+ Requirements
- {requiredScopes.length > 0 ? (
-
+ {selectedToolNames.length === 0 ? (
+
+ Select tools to see requirements
+
+ ) : (
+ <>
+ {/* Scopes indicator - only shown when advanced is enabled and there are scopes */}
+ {showAdvanced && requiredScopes.length > 0 && (
+
+
+
+ {requiredScopes.length} OAuth scope{requiredScopes.length > 1 ? "s" : ""} required
+
+
+ )}
+
+ {/* Secrets - always show the actual secret names */}
+
+
0 ? "text-amber-400" : "text-muted-foreground/50"}`} />
+ {requiredSecrets.length > 0 ? (
+
+ Secrets:
+ {requiredSecrets.map((secret) => (
+
+ {secret}
+
+ ))}
+
+ ) : (
+ No secrets required
+ )}
+
+
+ {/* Show OAuth Scopes Toggle - inside the box, only when there are scopes */}
+ {requiredScopes.length > 0 && (
+
+
+
+ )}
+ >
+ )}
+
+
+ {/* OAuth Scopes Details - only shown when advanced is enabled and there are scopes */}
+ {showAdvanced && selectedToolNames.length > 0 && requiredScopes.length > 0 && (
+
+
+
+
+ Required OAuth scopes
+
+ {requiredScopes.length}
+
+
+
+
+
{requiredScopes.map((scope) => (
-
{scope}
-
+
))}
-
- ) : (
-
- Select tools above to see required scopes
-
- )}
-
+
+
+ )}
);
}
+
+export { CopyButton };
diff --git a/app/_components/toolkit-docs/__tests__/AvailableToolsTable.test.tsx b/app/_components/toolkit-docs/__tests__/AvailableToolsTable.test.tsx
new file mode 100644
index 000000000..a955cd035
--- /dev/null
+++ b/app/_components/toolkit-docs/__tests__/AvailableToolsTable.test.tsx
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildScopeDisplayItems,
+ buildSecretDisplayItems,
+ filterTools,
+ toToolAnchorId,
+} from "../components/AvailableToolsTable";
+
+describe("AvailableToolsTable helpers", () => {
+ it("builds secret display items from secrets info", () => {
+ const items = buildSecretDisplayItems(
+ {
+ name: "CreateIssue",
+ qualifiedName: "Github.CreateIssue",
+ description: "Create an issue",
+ secretsInfo: [
+ { name: "GITHUB_API_KEY", type: "api_key" },
+ { name: "SECONDARY_TOKEN", type: "token" },
+ ],
+ },
+ { secretsDisplay: "summary" }
+ );
+
+ expect(items).toEqual([
+ { label: "API key", href: undefined },
+ { label: "Token", href: undefined },
+ ]);
+ });
+
+ it("adds hrefs for secret type docs when base URL is provided", () => {
+ const items = buildSecretDisplayItems(
+ {
+ name: "CreateIssue",
+ qualifiedName: "Github.CreateIssue",
+ description: "Create an issue",
+ secretsInfo: [{ name: "GITHUB_API_KEY", type: "api_key" }],
+ },
+ { secretsDisplay: "types", secretTypeDocsBaseUrl: "/references/secrets" }
+ );
+
+ expect(items).toEqual([
+ { label: "API key", href: "/references/secrets/api_key" },
+ ]);
+ });
+
+ it("filters tools by query and filter mode", () => {
+ const tools = [
+ {
+ name: "CreateIssue",
+ qualifiedName: "Github.CreateIssue",
+ description: "Create an issue",
+ scopes: ["repo"],
+ secrets: ["API_KEY"],
+ },
+ {
+ name: "ListRepos",
+ qualifiedName: "Github.ListRepos",
+ description: "List repositories",
+ scopes: [],
+ secrets: [],
+ },
+ ];
+
+ expect(filterTools(tools, "list", "all", [])).toEqual([tools[1]]);
+ expect(filterTools(tools, "", "has_scopes", [])).toEqual([tools[0]]);
+ expect(filterTools(tools, "", "no_secrets", [])).toEqual([tools[1]]);
+ expect(filterTools(tools, "", "all", ["repo"])).toEqual([tools[0]]);
+ expect(filterTools(tools, "", "all", ["missing"])).toEqual([]);
+ });
+
+ it("builds scope display items from scopes", () => {
+ expect(
+ buildScopeDisplayItems([" scope.one ", "", "scope.two", "scope.one"])
+ ).toEqual(["scope.one", "scope.two"]);
+ });
+
+
+ it("matches the anchor id logic used by TableOfContents", () => {
+ expect(toToolAnchorId("Github.CreateIssue")).toBe("githubcreateissue");
+ expect(toToolAnchorId("Slack Api.Send Message")).toBe(
+ "slack-api.send-message".replace(".", "")
+ );
+ });
+});
diff --git a/app/_components/toolkit-docs/__tests__/DocumentationChunkRenderer.test.tsx b/app/_components/toolkit-docs/__tests__/DocumentationChunkRenderer.test.tsx
new file mode 100644
index 000000000..29662d8fe
--- /dev/null
+++ b/app/_components/toolkit-docs/__tests__/DocumentationChunkRenderer.test.tsx
@@ -0,0 +1,160 @@
+import { describe, expect, it } from "vitest";
+
+import type { DocumentationChunk } from "../types";
+import { hasChunksAt } from "../components/DocumentationChunkRenderer";
+
+/**
+ * Unit tests for DocumentationChunkRenderer utility functions
+ *
+ * Note: Component rendering tests require additional dependencies
+ * (@testing-library/react). These tests focus on the pure utility functions.
+ */
+
+describe("hasChunksAt", () => {
+ const createChunk = (
+ overrides: Partial
= {}
+ ): DocumentationChunk => ({
+ type: "callout",
+ location: "description",
+ position: "before",
+ content: "Test content",
+ ...overrides,
+ });
+
+ it("returns true when chunks exist at location and position", () => {
+ const chunks: DocumentationChunk[] = [
+ createChunk({ location: "header", position: "after" }),
+ ];
+
+ expect(hasChunksAt(chunks, "header", "after")).toBe(true);
+ });
+
+ it("returns false when no chunks at location", () => {
+ const chunks: DocumentationChunk[] = [
+ createChunk({ location: "header", position: "after" }),
+ ];
+
+ expect(hasChunksAt(chunks, "parameters", "after")).toBe(false);
+ });
+
+ it("returns false when no chunks at position", () => {
+ const chunks: DocumentationChunk[] = [
+ createChunk({ location: "header", position: "after" }),
+ ];
+
+ expect(hasChunksAt(chunks, "header", "before")).toBe(false);
+ });
+
+ it("returns false for empty array", () => {
+ expect(hasChunksAt([], "header", "after")).toBe(false);
+ });
+
+ it("returns true when multiple chunks match", () => {
+ const chunks: DocumentationChunk[] = [
+ createChunk({ location: "description", position: "before" }),
+ createChunk({ location: "description", position: "before" }),
+ createChunk({ location: "description", position: "after" }),
+ ];
+
+ expect(hasChunksAt(chunks, "description", "before")).toBe(true);
+ });
+
+ it("works with all location types", () => {
+ const locations: DocumentationChunk["location"][] = [
+ "header",
+ "description",
+ "parameters",
+ "auth",
+ "secrets",
+ "output",
+ "footer",
+ ];
+
+ for (const location of locations) {
+ const chunks: DocumentationChunk[] = [
+ createChunk({ location, position: "after" }),
+ ];
+ expect(hasChunksAt(chunks, location, "after")).toBe(true);
+ }
+ });
+
+ it("works with all position types", () => {
+ const positions: DocumentationChunk["position"][] = [
+ "before",
+ "after",
+ "replace",
+ ];
+
+ for (const position of positions) {
+ const chunks: DocumentationChunk[] = [
+ createChunk({ location: "header", position }),
+ ];
+ expect(hasChunksAt(chunks, "header", position)).toBe(true);
+ }
+ });
+});
+
+describe("DocumentationChunk type validation", () => {
+ it("validates chunk type property", () => {
+ const validTypes: DocumentationChunk["type"][] = [
+ "callout",
+ "markdown",
+ "code",
+ "warning",
+ "info",
+ "tip",
+ ];
+
+ for (const type of validTypes) {
+ const chunk: DocumentationChunk = {
+ type,
+ location: "header",
+ position: "after",
+ content: "test",
+ };
+ expect(chunk.type).toBe(type);
+ }
+ });
+
+ it("validates optional title property", () => {
+ const chunkWithTitle: DocumentationChunk = {
+ type: "callout",
+ location: "header",
+ position: "after",
+ content: "test",
+ title: "My Title",
+ };
+
+ const chunkWithoutTitle: DocumentationChunk = {
+ type: "callout",
+ location: "header",
+ position: "after",
+ content: "test",
+ };
+
+ expect(chunkWithTitle.title).toBe("My Title");
+ expect(chunkWithoutTitle.title).toBeUndefined();
+ });
+
+ it("validates optional variant property", () => {
+ const validVariants: DocumentationChunk["variant"][] = [
+ "default",
+ "destructive",
+ "warning",
+ "info",
+ "success",
+ undefined,
+ ];
+
+ for (const variant of validVariants) {
+ const chunk: DocumentationChunk = {
+ type: "callout",
+ location: "header",
+ position: "after",
+ content: "test",
+ variant,
+ };
+ expect(chunk.variant).toBe(variant);
+ }
+ });
+});
diff --git a/app/_components/toolkit-docs/__tests__/DynamicCodeBlock.test.tsx b/app/_components/toolkit-docs/__tests__/DynamicCodeBlock.test.tsx
new file mode 100644
index 000000000..39edb3760
--- /dev/null
+++ b/app/_components/toolkit-docs/__tests__/DynamicCodeBlock.test.tsx
@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest";
+
+import type { ToolCodeExample } from "../types";
+import {
+ buildToolInput,
+ generateJavaScriptExample,
+ generatePythonExample,
+} from "../components/DynamicCodeBlock";
+
+describe("DynamicCodeBlock helpers", () => {
+ it("builds tool input with placeholders for required values", () => {
+ const codeExample: ToolCodeExample = {
+ toolName: "Github.CreateIssue",
+ parameters: {
+ title: {
+ value: null,
+ type: "string",
+ required: true,
+ },
+ body: {
+ value: null,
+ type: "string",
+ required: false,
+ },
+ },
+ requiresAuth: true,
+ authProvider: "github",
+ };
+
+ const toolInput = buildToolInput(codeExample.parameters);
+
+ expect(toolInput).toEqual({
+ title: "your_value",
+ });
+ });
+
+ it("generates JavaScript example with auth flow", () => {
+ const codeExample: ToolCodeExample = {
+ toolName: "Github.CreateIssue",
+ parameters: {
+ owner: {
+ value: "arcadeai",
+ type: "string",
+ required: true,
+ },
+ labels: {
+ value: ["bug", "high-priority"],
+ type: "array",
+ required: false,
+ },
+ },
+ requiresAuth: true,
+ authProvider: "github",
+ tabLabel: "Call the Tool with User Authorization",
+ };
+
+ const output = generateJavaScriptExample(codeExample);
+
+ expect(output).toContain("client.tools.authorize");
+ expect(output).toContain("user_id: USER_ID");
+ expect(output).toContain('const TOOL_NAME = "Github.CreateIssue";');
+ expect(output).toContain('owner: "arcadeai"');
+ expect(output).toContain('labels: ["bug", "high-priority"]');
+ });
+
+ it("generates Python example without auth flow when not required", () => {
+ const codeExample: ToolCodeExample = {
+ toolName: "Slack.ListChannels",
+ parameters: {
+ limit: {
+ value: 50,
+ type: "integer",
+ required: false,
+ },
+ },
+ requiresAuth: false,
+ };
+
+ const output = generatePythonExample(codeExample);
+
+ expect(output).not.toContain("authorize");
+ expect(output).not.toContain("USER_ID");
+ expect(output).toContain('TOOL_NAME = "Slack.ListChannels"');
+ expect(output).toContain("tool_input = {");
+ expect(output).toContain('"limit": 50');
+ });
+});
diff --git a/app/_components/toolkit-docs/__tests__/ParametersTable.test.tsx b/app/_components/toolkit-docs/__tests__/ParametersTable.test.tsx
new file mode 100644
index 000000000..a969fad7d
--- /dev/null
+++ b/app/_components/toolkit-docs/__tests__/ParametersTable.test.tsx
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+
+import type { ToolParameter } from "../types";
+import {
+ formatEnumValues,
+ formatParameterType,
+} from "../components/ParametersTable";
+
+describe("ParametersTable helpers", () => {
+ describe("formatParameterType", () => {
+ it("formats array types with inner type", () => {
+ const param: ToolParameter = {
+ name: "labels",
+ type: "array",
+ innerType: "string",
+ required: false,
+ description: "Labels to add",
+ enum: null,
+ };
+
+ expect(formatParameterType(param)).toBe("array");
+ });
+
+ it("returns base type when no inner type", () => {
+ const param: ToolParameter = {
+ name: "count",
+ type: "integer",
+ required: false,
+ description: "Count",
+ enum: null,
+ };
+
+ expect(formatParameterType(param)).toBe("integer");
+ });
+ });
+
+ describe("formatEnumValues", () => {
+ it("filters empty enum values", () => {
+ const values = ["open", "", "closed", " ", "all"];
+
+ expect(formatEnumValues(values)).toEqual(["open", "closed", "all"]);
+ });
+
+ it("returns empty array when enum is null", () => {
+ expect(formatEnumValues(null)).toEqual([]);
+ });
+
+ it("returns empty array when enum is undefined", () => {
+ expect(formatEnumValues(undefined)).toEqual([]);
+ });
+ });
+});
diff --git a/app/_components/toolkit-docs/__tests__/ScopesDisplay.test.tsx b/app/_components/toolkit-docs/__tests__/ScopesDisplay.test.tsx
new file mode 100644
index 000000000..ace4f6512
--- /dev/null
+++ b/app/_components/toolkit-docs/__tests__/ScopesDisplay.test.tsx
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { normalizeScopes } from "../components/ScopesDisplay";
+
+describe("ScopesDisplay helpers", () => {
+ it("trims scope values and removes duplicates", () => {
+ const scopes = [
+ "repo",
+ " user:email ",
+ "repo",
+ "public_repo",
+ "user:email",
+ ];
+
+ expect(normalizeScopes(scopes)).toEqual([
+ "repo",
+ "user:email",
+ "public_repo",
+ ]);
+ });
+
+ it("removes empty and whitespace-only scopes", () => {
+ const scopes = ["", " ", "repo", "\n", "user:email"];
+
+ expect(normalizeScopes(scopes)).toEqual(["repo", "user:email"]);
+ });
+
+ it("returns empty array when given empty array", () => {
+ expect(normalizeScopes([])).toEqual([]);
+ });
+});
diff --git a/app/_components/toolkit-docs/__tests__/ToolSection.test.tsx b/app/_components/toolkit-docs/__tests__/ToolSection.test.tsx
new file mode 100644
index 000000000..cab856296
--- /dev/null
+++ b/app/_components/toolkit-docs/__tests__/ToolSection.test.tsx
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+
+import type { DocumentationChunk } from "../types";
+import { shouldRenderDefaultSection } from "../components/ToolSection";
+
+describe("ToolSection helpers", () => {
+ it("returns true when no replace chunks exist", () => {
+ const chunks: DocumentationChunk[] = [
+ {
+ type: "callout",
+ location: "description",
+ position: "before",
+ content: "Info",
+ },
+ ];
+
+ expect(shouldRenderDefaultSection(chunks, "description")).toBe(true);
+ });
+
+ it("returns false when replace chunk exists for location", () => {
+ const chunks: DocumentationChunk[] = [
+ {
+ type: "markdown",
+ location: "parameters",
+ position: "replace",
+ content: "Custom parameters",
+ },
+ ];
+
+ expect(shouldRenderDefaultSection(chunks, "parameters")).toBe(false);
+ });
+});
diff --git a/app/_components/toolkit-docs/__tests__/ToolkitHeader.test.tsx b/app/_components/toolkit-docs/__tests__/ToolkitHeader.test.tsx
new file mode 100644
index 000000000..130c0aba7
--- /dev/null
+++ b/app/_components/toolkit-docs/__tests__/ToolkitHeader.test.tsx
@@ -0,0 +1,235 @@
+import { describe, expect, it } from "vitest";
+
+import type {
+ ToolkitHeaderProps,
+ ToolkitMetadata,
+ ToolkitAuth,
+ ToolkitCategory,
+ ToolkitType,
+} from "../types";
+
+/**
+ * Unit tests for ToolkitHeader types and props validation
+ *
+ * Note: Component rendering tests require additional dependencies
+ * (@testing-library/react). These tests focus on type validation
+ * and prop structure.
+ */
+
+describe("ToolkitHeaderProps type validation", () => {
+ const defaultMetadata: ToolkitMetadata = {
+ category: "development",
+ iconUrl: "https://example.com/icon.svg",
+ isBYOC: false,
+ isPro: false,
+ type: "arcade",
+ docsLink: "https://docs.arcade.dev/github",
+ };
+
+ const defaultAuth: ToolkitAuth = {
+ type: "oauth2",
+ providerId: "github",
+ allScopes: ["repo", "user:email"],
+ };
+
+ it("validates required props structure", () => {
+ const props: ToolkitHeaderProps = {
+ id: "Github",
+ label: "GitHub",
+ description: "GitHub repository tools",
+ metadata: defaultMetadata,
+ auth: defaultAuth,
+ };
+
+ expect(props.id).toBe("Github");
+ expect(props.label).toBe("GitHub");
+ expect(props.description).toBe("GitHub repository tools");
+ expect(props.metadata).toEqual(defaultMetadata);
+ expect(props.auth).toEqual(defaultAuth);
+ });
+
+ it("validates optional props", () => {
+ const props: ToolkitHeaderProps = {
+ id: "Github",
+ label: "GitHub",
+ description: "GitHub repository tools",
+ metadata: defaultMetadata,
+ auth: defaultAuth,
+ version: "1.0.0",
+ author: "Custom Author",
+ summary: "A summary of the toolkit",
+ };
+
+ expect(props.version).toBe("1.0.0");
+ expect(props.author).toBe("Custom Author");
+ expect(props.summary).toBe("A summary of the toolkit");
+ });
+
+ it("allows null description", () => {
+ const props: ToolkitHeaderProps = {
+ id: "Github",
+ label: "GitHub",
+ description: null,
+ metadata: defaultMetadata,
+ auth: defaultAuth,
+ };
+
+ expect(props.description).toBeNull();
+ });
+
+ it("allows null auth", () => {
+ const props: ToolkitHeaderProps = {
+ id: "Github",
+ label: "GitHub",
+ description: "Description",
+ metadata: defaultMetadata,
+ auth: null,
+ };
+
+ expect(props.auth).toBeNull();
+ });
+});
+
+describe("ToolkitMetadata type validation", () => {
+ it("validates all category values", () => {
+ const categories: ToolkitCategory[] = [
+ "productivity",
+ "social",
+ "development",
+ "entertainment",
+ "search",
+ "payments",
+ "sales",
+ "databases",
+ "customer-support",
+ ];
+
+ for (const category of categories) {
+ const metadata: ToolkitMetadata = {
+ category,
+ iconUrl: "https://example.com/icon.svg",
+ isBYOC: false,
+ isPro: false,
+ type: "arcade",
+ docsLink: "https://docs.arcade.dev",
+ };
+ expect(metadata.category).toBe(category);
+ }
+ });
+
+ it("validates all type values", () => {
+ const types: ToolkitType[] = [
+ "arcade",
+ "arcade_starter",
+ "verified",
+ "community",
+ "auth",
+ ];
+
+ for (const type of types) {
+ const metadata: ToolkitMetadata = {
+ category: "development",
+ iconUrl: "https://example.com/icon.svg",
+ isBYOC: false,
+ isPro: false,
+ type,
+ docsLink: "https://docs.arcade.dev",
+ };
+ expect(metadata.type).toBe(type);
+ }
+ });
+
+ it("validates boolean flags", () => {
+ const metadata: ToolkitMetadata = {
+ category: "development",
+ iconUrl: "https://example.com/icon.svg",
+ isBYOC: true,
+ isPro: true,
+ type: "arcade",
+ docsLink: "https://docs.arcade.dev",
+ isComingSoon: true,
+ isHidden: false,
+ };
+
+ expect(metadata.isBYOC).toBe(true);
+ expect(metadata.isPro).toBe(true);
+ expect(metadata.isComingSoon).toBe(true);
+ expect(metadata.isHidden).toBe(false);
+ });
+});
+
+describe("ToolkitAuth type validation", () => {
+ it("validates oauth2 auth type", () => {
+ const auth: ToolkitAuth = {
+ type: "oauth2",
+ providerId: "github",
+ allScopes: ["repo", "user:email"],
+ };
+
+ expect(auth.type).toBe("oauth2");
+ expect(auth.providerId).toBe("github");
+ expect(auth.allScopes).toEqual(["repo", "user:email"]);
+ });
+
+ it("validates api_key auth type", () => {
+ const auth: ToolkitAuth = {
+ type: "api_key",
+ providerId: null,
+ allScopes: [],
+ };
+
+ expect(auth.type).toBe("api_key");
+ expect(auth.providerId).toBeNull();
+ expect(auth.allScopes).toEqual([]);
+ });
+
+ it("validates mixed auth type", () => {
+ const auth: ToolkitAuth = {
+ type: "mixed",
+ providerId: "github",
+ allScopes: ["repo"],
+ };
+
+ expect(auth.type).toBe("mixed");
+ expect(auth.providerId).toBe("github");
+ expect(auth.allScopes).toEqual(["repo"]);
+ });
+
+ it("validates none auth type", () => {
+ const auth: ToolkitAuth = {
+ type: "none",
+ providerId: null,
+ allScopes: [],
+ };
+
+ expect(auth.type).toBe("none");
+ });
+
+ it("validates empty scopes array", () => {
+ const auth: ToolkitAuth = {
+ type: "oauth2",
+ providerId: "custom",
+ allScopes: [],
+ };
+
+ expect(auth.allScopes).toEqual([]);
+ expect(auth.allScopes.length).toBe(0);
+ });
+
+ it("validates multiple scopes", () => {
+ const auth: ToolkitAuth = {
+ type: "oauth2",
+ providerId: "google",
+ allScopes: [
+ "https://www.googleapis.com/auth/gmail.readonly",
+ "https://www.googleapis.com/auth/gmail.send",
+ "https://www.googleapis.com/auth/gmail.compose",
+ ],
+ };
+
+ expect(auth.allScopes.length).toBe(3);
+ expect(auth.allScopes).toContain(
+ "https://www.googleapis.com/auth/gmail.readonly"
+ );
+ });
+});
diff --git a/app/_components/toolkit-docs/__tests__/ToolkitPage.test.tsx b/app/_components/toolkit-docs/__tests__/ToolkitPage.test.tsx
new file mode 100644
index 000000000..054e88a15
--- /dev/null
+++ b/app/_components/toolkit-docs/__tests__/ToolkitPage.test.tsx
@@ -0,0 +1,11 @@
+import { describe, expect, it } from "vitest";
+
+import { buildPipPackageName } from "../components/ToolkitPage";
+
+describe("ToolkitPage helpers", () => {
+ it("builds a pip package name from toolkit id", () => {
+ expect(buildPipPackageName("Github")).toBe("arcade_github");
+ expect(buildPipPackageName("Google Sheets")).toBe("arcade_google_sheets");
+ expect(buildPipPackageName("Slack-Api")).toBe("arcade_slack_api");
+ });
+});
diff --git a/app/_components/toolkit-docs/components/AvailableToolsTable.tsx b/app/_components/toolkit-docs/components/AvailableToolsTable.tsx
new file mode 100644
index 000000000..a5a5530af
--- /dev/null
+++ b/app/_components/toolkit-docs/components/AvailableToolsTable.tsx
@@ -0,0 +1,552 @@
+"use client";
+
+import { Check, KeyRound } from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
+
+import { SCROLLING_CELL } from "../constants";
+import type { AvailableToolsTableProps, SecretType } from "../types";
+import { normalizeScopes } from "./ScopesDisplay";
+
+/**
+ * A cell content wrapper that auto-scrolls on hover when content overflows.
+ * Scroll duration is proportional to content length.
+ * Only scrolls when this specific cell is hovered, with a delay.
+ */
+function ScrollingCell({
+ children,
+ className = "",
+ pixelsPerSecond = SCROLLING_CELL.pixelsPerSecond,
+ delay = SCROLLING_CELL.delayMs,
+}: {
+ children: React.ReactNode;
+ className?: string;
+ pixelsPerSecond?: number;
+ delay?: number;
+}) {
+ const containerRef = useRef(null);
+ const contentRef = useRef(null);
+ const [isOverflowing, setIsOverflowing] = useState(false);
+ const [isHovered, setIsHovered] = useState(false);
+ const [scrollOffset, setScrollOffset] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const hoverTimeoutRef = useRef(null);
+
+ useEffect(() => {
+ const checkOverflow = () => {
+ if (containerRef.current && contentRef.current) {
+ const containerWidth = containerRef.current.offsetWidth;
+ const contentWidth = contentRef.current.scrollWidth;
+ const overflow = contentWidth - containerWidth;
+ setIsOverflowing(overflow > 0);
+ setScrollOffset(overflow + SCROLLING_CELL.extraPadding);
+ // Calculate duration based on scroll distance
+ setDuration(Math.max(SCROLLING_CELL.minDurationMs, (overflow + SCROLLING_CELL.extraPadding) / pixelsPerSecond * 1000));
+ }
+ };
+ checkOverflow();
+ window.addEventListener("resize", checkOverflow);
+ return () => window.removeEventListener("resize", checkOverflow);
+ }, [children, pixelsPerSecond]);
+
+ const handleMouseEnter = () => {
+ if (isOverflowing) {
+ hoverTimeoutRef.current = setTimeout(() => {
+ setIsHovered(true);
+ }, delay);
+ }
+ };
+
+ const handleMouseLeave = () => {
+ if (hoverTimeoutRef.current) {
+ clearTimeout(hoverTimeoutRef.current);
+ hoverTimeoutRef.current = null;
+ }
+ setIsHovered(false);
+ };
+
+ useEffect(() => {
+ return () => {
+ if (hoverTimeoutRef.current) {
+ clearTimeout(hoverTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ return (
+
+
+ {children}
+
+ {isOverflowing && (
+
+ )}
+
+ );
+}
+
+export function toToolAnchorId(value: string): string {
+ return value.toLowerCase().replace(/\s+/g, "-").replace(".", "");
+}
+
+export type AvailableToolsFilter =
+ | "all"
+ | "has_scopes"
+ | "no_scopes"
+ | "has_secrets"
+ | "no_secrets";
+
+export type AvailableToolsSort =
+ | "name_asc"
+ | "name_desc"
+ | "scopes_first"
+ | "secrets_first"
+ | "selected_first";
+
+type SecretDisplayItem = {
+ label: string;
+ href?: string;
+};
+
+const DEFAULT_SECRET_LABELS: Record = {
+ api_key: "API key",
+ token: "Token",
+ client_secret: "Client secret",
+ webhook_secret: "Webhook secret",
+ private_key: "Private key",
+ password: "Password",
+ unknown: "Unknown",
+};
+
+function normalizeBaseUrl(baseUrl?: string): string | undefined {
+ if (!baseUrl) {
+ return undefined;
+ }
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
+}
+
+export function buildSecretDisplayItems(
+ tool: AvailableToolsTableProps["tools"][number],
+ options?: Pick<
+ AvailableToolsTableProps,
+ "secretsDisplay" | "secretTypeLabels" | "secretTypeDocsBaseUrl"
+ >
+): SecretDisplayItem[] {
+ const displayMode = options?.secretsDisplay ?? "summary";
+ const secretsInfo = tool.secretsInfo ?? [];
+ const secrets = tool.secrets ?? [];
+ const secretTypeLabels = {
+ ...DEFAULT_SECRET_LABELS,
+ ...options?.secretTypeLabels,
+ };
+ const baseUrl = normalizeBaseUrl(options?.secretTypeDocsBaseUrl);
+
+ if (displayMode === "names") {
+ return secrets.map((secret) => ({ label: secret }));
+ }
+
+ if (
+ displayMode === "types" ||
+ (displayMode === "summary" && secretsInfo.length > 0)
+ ) {
+ const uniqueTypes = Array.from(
+ new Set(secretsInfo.map((secret) => secret.type))
+ );
+
+ return uniqueTypes.map((type) => ({
+ label: secretTypeLabels[type] ?? "Unknown",
+ href: baseUrl ? `${baseUrl}/${type}` : undefined,
+ }));
+ }
+
+ return secrets.map((secret) => ({ label: secret }));
+}
+
+export function buildScopeDisplayItems(scopes: string[]): string[] {
+ return normalizeScopes(scopes);
+}
+
+export function sortTools(
+ tools: AvailableToolsTableProps["tools"],
+ sort: AvailableToolsSort,
+ selectedTools?: Set
+): AvailableToolsTableProps["tools"] {
+ const sorted = [...tools];
+
+ const countSecrets = (tool: (typeof tools)[number]): number => {
+ const names = [
+ ...(tool.secrets ?? []),
+ ...((tool.secretsInfo ?? []).map((secret) => secret.name)),
+ ];
+ return new Set(names).size;
+ };
+
+ switch (sort) {
+ case "name_asc":
+ return sorted.sort((a, b) => a.name.localeCompare(b.name));
+ case "name_desc":
+ return sorted.sort((a, b) => b.name.localeCompare(a.name));
+ case "scopes_first":
+ return sorted.sort((a, b) => {
+ const aScopes = (a.scopes ?? []).length;
+ const bScopes = (b.scopes ?? []).length;
+ if (aScopes !== bScopes) return bScopes - aScopes;
+ return a.name.localeCompare(b.name);
+ });
+ case "secrets_first":
+ return sorted.sort((a, b) => {
+ const aSecrets = countSecrets(a);
+ const bSecrets = countSecrets(b);
+ if (aSecrets !== bSecrets) return bSecrets - aSecrets;
+ return a.name.localeCompare(b.name);
+ });
+ case "selected_first":
+ return sorted.sort((a, b) => {
+ const aSelected = selectedTools?.has(a.name) ? 1 : 0;
+ const bSelected = selectedTools?.has(b.name) ? 1 : 0;
+ if (aSelected !== bSelected) return bSelected - aSelected;
+ return a.name.localeCompare(b.name);
+ });
+ default:
+ return sorted;
+ }
+}
+
+export function filterTools(
+ tools: AvailableToolsTableProps["tools"],
+ query: string,
+ filter: AvailableToolsFilter,
+ selectedScopes: string[]
+): AvailableToolsTableProps["tools"] {
+ const normalizedQuery = query.trim().toLowerCase();
+
+ return tools.filter((tool) => {
+ const haystack = [
+ tool.name,
+ tool.qualifiedName,
+ tool.description ?? "",
+ ]
+ .join(" ")
+ .toLowerCase();
+ const matchesQuery =
+ normalizedQuery.length === 0 || haystack.includes(normalizedQuery);
+
+ if (!matchesQuery) {
+ return false;
+ }
+
+ const toolScopes = buildScopeDisplayItems(tool.scopes ?? []);
+ const hasScopes = toolScopes.length > 0;
+ const hasSecrets =
+ (tool.secretsInfo?.length ?? 0) > 0 || (tool.secrets?.length ?? 0) > 0;
+
+ const matchesScopes =
+ selectedScopes.length === 0 ||
+ selectedScopes.some((scope) => toolScopes.includes(scope));
+
+ if (!matchesScopes) {
+ return false;
+ }
+
+ switch (filter) {
+ case "has_scopes":
+ return hasScopes;
+ case "no_scopes":
+ return !hasScopes;
+ case "has_secrets":
+ return hasSecrets;
+ case "no_secrets":
+ return !hasSecrets;
+ case "all":
+ default:
+ return true;
+ }
+ });
+}
+
+function truncateItems(items: string[], maxItems: number) {
+ if (items.length <= maxItems) {
+ return { visibleItems: items, remainingCount: 0 };
+ }
+ return {
+ visibleItems: items.slice(0, maxItems),
+ remainingCount: items.length - maxItems,
+ };
+}
+
+/**
+ * AvailableToolsTable
+ *
+ * Renders a table of tools with clickable rows.
+ */
+export function AvailableToolsTable({
+ tools,
+ secretsColumnLabel = "Secrets",
+ secretsDisplay = "summary",
+ secretTypeLabels,
+ secretTypeDocsBaseUrl,
+ enableSearch = true,
+ enableFilters = true,
+ enableScopeFilter = true,
+ searchPlaceholder = "Search tools...",
+ filterLabel = "Filter",
+ scopeFilterLabel = "Filter by scope",
+ scopeFilterDescription = "Select scopes to narrow the tool list.",
+ defaultFilter = "all",
+ selectedTools,
+ onToggleSelection,
+ showSelection = false,
+}: AvailableToolsTableProps) {
+ if (!tools || tools.length === 0) {
+ return (
+
+ No tools available.
+
+ );
+ }
+
+ const [query, setQuery] = useState("");
+ const [filter, setFilter] = useState(defaultFilter);
+ const [sort, setSort] = useState("name_asc");
+ const [selectedScopes, setSelectedScopes] = useState([]);
+
+ const availableScopes = useMemo(() => {
+ const allScopes = tools.flatMap((tool) =>
+ buildScopeDisplayItems(tool.scopes ?? [])
+ );
+ return Array.from(new Set(allScopes)).sort();
+ }, [tools]);
+
+ const filteredTools = useMemo(() => {
+ const filtered = filterTools(tools, query, filter, selectedScopes);
+ return sortTools(filtered, sort, selectedTools);
+ }, [tools, query, filter, selectedScopes, sort, selectedTools]);
+
+ const toggleScope = (scope: string) => {
+ setSelectedScopes((current) => {
+ if (current.includes(scope)) {
+ return current.filter((item) => item !== scope);
+ }
+ return [...current, scope];
+ });
+ };
+
+ const clearScopes = () => {
+ setSelectedScopes([]);
+ };
+
+ return (
+
+ {(enableSearch || enableFilters) && (
+
+ {enableSearch && (
+
+
+
setQuery(event.target.value)}
+ placeholder={searchPlaceholder}
+ type="search"
+ value={query}
+ />
+
+ )}
+ {enableFilters && (
+
+ )}
+
+
+ {filteredTools.length} of {tools.length}
+
+
+ )}
+ {filteredTools.length === 0 ? (
+
+
No tools match your search.
+
+ ) : (
+
+
+
+
+
+ {showSelection && (
+ |
+
+ |
+ )}
+
+ Tool name
+ |
+
+ Description
+ |
+
+
+
+ {secretsColumnLabel}
+
+ |
+
+
+
+ {filteredTools.map((tool, index) => {
+ const scopes = buildScopeDisplayItems(tool.scopes ?? []);
+ const { visibleItems, remainingCount } = truncateItems(scopes, 2);
+ const secretItems = buildSecretDisplayItems(tool, {
+ secretsDisplay,
+ secretTypeLabels,
+ secretTypeDocsBaseUrl,
+ });
+ const isSelected = selectedTools?.has(tool.name) ?? false;
+ const rowBg = isSelected
+ ? "bg-brand-accent/10"
+ : index % 2 === 0
+ ? "bg-neutral-dark/20"
+ : "bg-neutral-dark/5";
+
+ return (
+ {
+ window.location.hash = `#${toToolAnchorId(tool.qualifiedName)}`;
+ }}
+ >
+ {showSelection && (
+ |
+
+ |
+ )}
+
+
+
+ {tool.qualifiedName}
+
+
+ |
+
+
+
+ {tool.description ?? "No description provided."}
+
+
+ |
+
+ {secretItems.length === 0 ? (
+ —
+ ) : (
+
+ {secretItems.map((item) =>
+ item.href ? (
+
+
+ {item.label}
+
+
+ ) : (
+
+ {item.label}
+
+ )
+ )}
+
+ )}
+ |
+
+ );
+ })}
+
+
+
+
+ )}
+
+ );
+}
+
+export default AvailableToolsTable;
diff --git a/app/_components/toolkit-docs/components/DocumentationChunkRenderer.tsx b/app/_components/toolkit-docs/components/DocumentationChunkRenderer.tsx
new file mode 100644
index 000000000..9e69c824b
--- /dev/null
+++ b/app/_components/toolkit-docs/components/DocumentationChunkRenderer.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { Callout } from "nextra/components";
+import type React from "react";
+import ReactMarkdown from "react-markdown";
+
+import type {
+ DocumentationChunk,
+ DocumentationChunkLocation,
+ DocumentationChunkPosition,
+ DocumentationChunkRendererProps,
+} from "../types";
+
+/**
+ * Maps chunk types to Nextra Callout types
+ */
+const CALLOUT_TYPE_MAP: Record = {
+ callout: "default",
+ info: "info",
+ tip: "info",
+ warning: "warning",
+ error: "error",
+};
+
+/**
+ * Maps chunk variants to Nextra Callout types
+ */
+const VARIANT_TYPE_MAP: Record = {
+ default: "default",
+ info: "info",
+ success: "info",
+ warning: "warning",
+ destructive: "error",
+};
+
+/**
+ * Renders markdown content from a string.
+ */
+function MarkdownContent({ content }: { content: string }) {
+ return {content};
+}
+
+/**
+ * Renders a single documentation chunk
+ */
+function ChunkContent({ chunk }: { chunk: DocumentationChunk }) {
+ const { type, content, title, variant } = chunk;
+
+ // Handle code blocks
+ if (type === "code") {
+ return (
+
+ {content}
+
+ );
+ }
+
+ // Handle plain markdown
+ if (type === "markdown") {
+ return (
+
+
+
+ );
+ }
+
+ // Handle callout types (callout, info, tip, warning)
+ // Determine the callout type from chunk type or variant
+ let calloutType: "default" | "info" | "warning" | "error" = "default";
+
+ if (variant && VARIANT_TYPE_MAP[variant]) {
+ calloutType = VARIANT_TYPE_MAP[variant];
+ } else if (CALLOUT_TYPE_MAP[type]) {
+ calloutType = CALLOUT_TYPE_MAP[type];
+ }
+
+ return (
+
+
+
+ );
+}
+
+/**
+ * DocumentationChunkRenderer
+ *
+ * A generic component for rendering custom documentation content at specified
+ * injection points. Filters chunks by location and position, then renders
+ * the appropriate component based on chunk type.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ *
+ * Supported chunk types:
+ * - `callout`: Default callout box
+ * - `info`: Blue info callout
+ * - `tip`: Blue tip callout
+ * - `warning`: Yellow warning callout
+ * - `markdown`: Raw markdown content
+ * - `code`: Code block
+ */
+export function DocumentationChunkRenderer({
+ chunks,
+ location,
+ position,
+ className,
+}: DocumentationChunkRendererProps): React.ReactElement | null {
+ // Filter chunks that match the specified location and position
+ const matchingChunks = chunks.filter(
+ (chunk) => chunk.location === location && chunk.position === position
+ );
+
+ // Return null if no matching chunks
+ if (matchingChunks.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {matchingChunks.map((chunk, index) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * Helper function to check if chunks exist for a given location and position
+ * Useful for conditional rendering
+ */
+export function hasChunksAt(
+ chunks: DocumentationChunk[],
+ location: DocumentationChunkLocation,
+ position: DocumentationChunkPosition
+): boolean {
+ return chunks.some(
+ (chunk) => chunk.location === location && chunk.position === position
+ );
+}
+
+export default DocumentationChunkRenderer;
diff --git a/app/_components/toolkit-docs/components/DynamicCodeBlock.tsx b/app/_components/toolkit-docs/components/DynamicCodeBlock.tsx
new file mode 100644
index 000000000..7afa40af1
--- /dev/null
+++ b/app/_components/toolkit-docs/components/DynamicCodeBlock.tsx
@@ -0,0 +1,410 @@
+"use client";
+
+import { X } from "lucide-react";
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
+import { useEffect, useMemo, useState } from "react";
+
+import { CopyButton } from "../../tabbed-code-block/copy-button";
+import type {
+ DynamicCodeBlockProps,
+ ExampleParameterValue,
+ ToolCodeExample,
+} from "../types";
+
+/**
+ * Python logo SVG icon
+ */
+function PythonIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+/**
+ * TypeScript logo SVG icon
+ */
+function TypeScriptIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+type LanguageKey = "python" | "javascript";
+
+const LANGUAGE_LABELS: Record = {
+ python: "Python",
+ javascript: "JavaScript",
+};
+
+const DEFAULT_LANGUAGES: LanguageKey[] = ["python", "javascript"];
+
+const PLACEHOLDER_VALUES: Record = {
+ string: "your_value",
+ integer: 123,
+ boolean: true,
+ array: [],
+ object: {},
+};
+
+const INDENT_SIZE: Record = {
+ python: 4,
+ javascript: 2,
+};
+
+function indent(level: number, language: LanguageKey): string {
+ return " ".repeat(level * INDENT_SIZE[language]);
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ !Array.isArray(value) &&
+ !(value instanceof Date)
+ );
+}
+
+function formatKey(key: string, language: LanguageKey): string {
+ if (language === "python") {
+ return JSON.stringify(key);
+ }
+
+ const isValidIdentifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key);
+ return isValidIdentifier ? key : JSON.stringify(key);
+}
+
+function serializePrimitive(value: unknown, language: LanguageKey): string {
+ if (value === null) {
+ return language === "python" ? "None" : "null";
+ }
+
+ switch (typeof value) {
+ case "string":
+ return JSON.stringify(value);
+ case "number":
+ return Number.isFinite(value) ? String(value) : "null";
+ case "boolean":
+ return language === "python" ? (value ? "True" : "False") : String(value);
+ default:
+ return language === "python" ? "None" : "null";
+ }
+}
+
+function shouldInline(values: string[]): boolean {
+ return values.every((value) => !value.includes("\n")) && values.join(", ").length < 80;
+}
+
+export function serializeValue(
+ value: unknown,
+ language: LanguageKey,
+ level: number
+): string {
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return "[]";
+ }
+
+ const items = value.map((item) => serializeValue(item, language, level + 1));
+ if (shouldInline(items)) {
+ return `[${items.join(", ")}]`;
+ }
+
+ const lines = items.map((item) => `${indent(level + 1, language)}${item}`);
+ return `[\n${lines.join(",\n")}\n${indent(level, language)}]`;
+ }
+
+ if (isPlainObject(value)) {
+ const entries = Object.entries(value);
+ if (entries.length === 0) {
+ return "{}";
+ }
+
+ const lines = entries.map(([key, entryValue]) => {
+ const formattedKey = formatKey(key, language);
+ const formattedValue = serializeValue(entryValue, language, level + 1);
+ return `${indent(level + 1, language)}${formattedKey}: ${formattedValue}`;
+ });
+
+ return `{\n${lines.join(",\n")}\n${indent(level, language)}}`;
+ }
+
+ return serializePrimitive(value, language);
+}
+
+export function resolveExampleValue(param: ExampleParameterValue): unknown {
+ if (param.value === null || param.value === undefined) {
+ return param.required ? PLACEHOLDER_VALUES[param.type] : undefined;
+ }
+
+ return param.value;
+}
+
+export function buildToolInput(
+ parameters: ToolCodeExample["parameters"]
+): Record {
+ const entries = Object.entries(parameters)
+ .map(([key, param]) => [key, resolveExampleValue(param)] as const)
+ .filter(([, value]) => value !== undefined);
+
+ return Object.fromEntries(entries);
+}
+
+function buildExecuteArgs(language: LanguageKey, includeUserId: boolean): string {
+ if (language === "python") {
+ const args = [
+ "tool_name=TOOL_NAME,",
+ "input=tool_input,",
+ includeUserId ? "user_id=USER_ID," : null,
+ ].filter(Boolean);
+
+ return [
+ "response = client.tools.execute(",
+ ...args.map((arg) => `${indent(1, language)}${arg}`),
+ ")",
+ ].join("\n");
+ }
+
+ const args = [
+ "tool_name: TOOL_NAME,",
+ "input: toolInput,",
+ includeUserId ? "user_id: USER_ID," : null,
+ ].filter(Boolean);
+
+ return [
+ "const response = await client.tools.execute({",
+ ...args.map((arg) => `${indent(1, language)}${arg}`),
+ "});",
+ ].join("\n");
+}
+
+export function generateJavaScriptExample(codeExample: ToolCodeExample): string {
+ const toolInput = buildToolInput(codeExample.parameters);
+ const toolInputLiteral = serializeValue(toolInput, "javascript", 0);
+ const lines: string[] = [
+ 'import { Arcade } from "@arcadeai/arcadejs";',
+ "",
+ "const client = new Arcade(); // Automatically finds the `ARCADE_API_KEY` env variable",
+ "",
+ ];
+
+ if (codeExample.requiresAuth) {
+ lines.push('const USER_ID = "{arcade_user_id}";');
+ }
+
+ lines.push(`const TOOL_NAME = "${codeExample.toolName}";`, "");
+
+ if (codeExample.requiresAuth) {
+ lines.push(
+ "const authResponse = await client.tools.authorize({",
+ `${indent(1, "javascript")}tool_name: TOOL_NAME,`,
+ `${indent(1, "javascript")}user_id: USER_ID,`,
+ "});",
+ "",
+ 'if (authResponse.status !== "completed") {',
+ `${indent(1, "javascript")}console.log(\`Click this link to authorize: \${authResponse.url}\`);`,
+ "}",
+ "",
+ "await client.auth.waitForCompletion(authResponse);",
+ ""
+ );
+ }
+
+ lines.push(`const toolInput = ${toolInputLiteral};`, "");
+
+ lines.push(buildExecuteArgs("javascript", codeExample.requiresAuth), "");
+
+ lines.push("console.log(response);");
+
+ return lines.join("\n");
+}
+
+export function generatePythonExample(codeExample: ToolCodeExample): string {
+ const toolInput = buildToolInput(codeExample.parameters);
+ const toolInputLiteral = serializeValue(toolInput, "python", 0);
+ const lines: string[] = ["from arcadepy import Arcade", "", "client = Arcade()", ""];
+
+ if (codeExample.requiresAuth) {
+ lines.push('USER_ID = "{arcade_user_id}"');
+ }
+
+ lines.push(`TOOL_NAME = "${codeExample.toolName}"`, "");
+
+ if (codeExample.requiresAuth) {
+ lines.push(
+ "auth_response = client.tools.authorize(",
+ `${indent(1, "python")}tool_name=TOOL_NAME,`,
+ `${indent(1, "python")}user_id=USER_ID,`,
+ ")",
+ "",
+ 'if auth_response.status != "completed":',
+ `${indent(1, "python")}print(f"Click this link to authorize: {auth_response.url}")`,
+ "",
+ "client.auth.wait_for_completion(auth_response)",
+ ""
+ );
+ }
+
+ lines.push(`tool_input = ${toolInputLiteral}`, "");
+
+ lines.push(buildExecuteArgs("python", codeExample.requiresAuth), "");
+
+ lines.push("print(response)");
+
+ return lines.join("\n");
+}
+
+/**
+ * Code popup modal for displaying examples
+ */
+function CodePopup({
+ code,
+ language,
+ onClose,
+}: {
+ code: string;
+ language: "python" | "typescript";
+ onClose: () => void;
+}) {
+ const displayName = language === "python" ? "Python" : "TypeScript";
+ const Icon = language === "python" ? PythonIcon : TypeScriptIcon;
+ const iconColor = language === "python" ? "text-[#3776AB]" : "text-[#3178C6]";
+
+ // Close on escape key
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
+ }, [onClose]);
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+
+
+
+
+ {displayName} Example
+
+
+
+
+
+
+
+
+ {/* Code */}
+
+
+ {code}
+
+
+
+
+ );
+}
+
+/**
+ * DynamicCodeBlock
+ *
+ * Generates and renders JavaScript and Python code examples dynamically
+ * from a ToolCodeExample configuration.
+ */
+export function DynamicCodeBlock({
+ codeExample,
+ languages = DEFAULT_LANGUAGES,
+ tabLabel,
+}: DynamicCodeBlockProps) {
+ const [activePopup, setActivePopup] = useState<"python" | "typescript" | null>(null);
+
+ const codeByLanguage = useMemo(
+ () => ({
+ python: generatePythonExample(codeExample),
+ typescript: generateJavaScriptExample(codeExample),
+ }),
+ [codeExample]
+ );
+
+ const displayLabel = tabLabel ?? codeExample.tabLabel;
+
+ const showPython = languages.includes("python");
+ const showTypeScript = languages.includes("javascript");
+
+ return (
+
+ {displayLabel && (
+
{displayLabel}
+ )}
+
+ {showPython && (
+
+ )}
+ {showTypeScript && (
+
+ )}
+
+
+ {/* Popup Modal */}
+ {activePopup && (
+
setActivePopup(null)}
+ />
+ )}
+
+ );
+}
+
+export default DynamicCodeBlock;
diff --git a/app/_components/toolkit-docs/components/ParametersTable.tsx b/app/_components/toolkit-docs/components/ParametersTable.tsx
new file mode 100644
index 000000000..160d26d5e
--- /dev/null
+++ b/app/_components/toolkit-docs/components/ParametersTable.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { CheckCircle2 } from "lucide-react";
+
+import type { ParametersTableProps, ToolParameter } from "../types";
+
+/**
+ * Formats a parameter type for display.
+ */
+export function formatParameterType(param: ToolParameter): string {
+ if (param.type === "array" && param.innerType) {
+ return `array<${param.innerType}>`;
+ }
+
+ return param.type;
+}
+
+/**
+ * Formats enum values for display.
+ */
+export function formatEnumValues(
+ enumValues: string[] | null | undefined
+): string[] {
+ if (!enumValues || enumValues.length === 0) {
+ return [];
+ }
+
+ return enumValues.filter((value) => value.trim().length > 0);
+}
+
+/**
+ * ParametersTable
+ *
+ * Renders a table of tool parameters with type, required, and description.
+ */
+export function ParametersTable({
+ parameters,
+ enumBaseUrl,
+}: ParametersTableProps) {
+ if (!parameters || parameters.length === 0) {
+ return (
+
+
No parameters required.
+
+ );
+ }
+
+ return (
+
+
+
+
+ |
+ Parameter
+ |
+
+ Type
+ |
+
+ Req.
+ |
+
+ Description
+ |
+
+
+
+ {parameters.map((param, index) => {
+ const enumValues = formatEnumValues(param.enum);
+
+ return (
+
+ |
+
+ {param.required && (
+
+ )}
+ {param.name}
+
+ |
+
+
+ {formatParameterType(param)}
+
+ |
+
+ {param.required ? (
+
+ ) : (
+ —
+ )}
+ |
+
+ {param.description ?? "No description provided."}
+ {enumValues.length > 0 && (
+
+ {enumValues.map((value) => {
+ const chip = (
+
+ {value}
+
+ );
+
+ if (enumBaseUrl) {
+ return (
+
+ {chip}
+
+ );
+ }
+
+ return {chip};
+ })}
+
+ )}
+ |
+
+ );
+ })}
+
+
+
+ );
+}
+
+export default ParametersTable;
diff --git a/app/_components/toolkit-docs/components/ScopesDisplay.tsx b/app/_components/toolkit-docs/components/ScopesDisplay.tsx
new file mode 100644
index 000000000..b65bd6017
--- /dev/null
+++ b/app/_components/toolkit-docs/components/ScopesDisplay.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { ShieldCheck } from "lucide-react";
+
+import type { ScopesDisplayProps } from "../types";
+
+/**
+ * Normalizes scope values by trimming and removing duplicates.
+ */
+export function normalizeScopes(scopes: string[]): string[] {
+ const uniqueScopes = new Set();
+
+ for (const scope of scopes) {
+ const trimmed = scope.trim();
+ if (trimmed.length > 0) {
+ uniqueScopes.add(trimmed);
+ }
+ }
+
+ return Array.from(uniqueScopes);
+}
+
+function ScopesInline({ scopes }: { scopes: string[] }) {
+ if (scopes.length === 0) {
+ return (
+
+ None required
+
+ );
+ }
+
+ return (
+
+ {scopes.map((scope) => (
+
+ {scope}
+
+ ))}
+
+ );
+}
+
+function ScopesList({ scopes }: { scopes: string[] }) {
+ if (scopes.length === 0) {
+ return (
+ None required
+ );
+ }
+
+ return (
+
+ {scopes.map((scope) => (
+
+ {scope}
+
+ ))}
+
+ );
+}
+
+/**
+ * ScopesDisplay
+ *
+ * Renders OAuth scopes inline or inside a callout.
+ */
+export function ScopesDisplay({
+ scopes,
+ variant = "inline",
+ title,
+}: ScopesDisplayProps) {
+ const normalizedScopes = normalizeScopes(scopes);
+
+ if (variant === "callout") {
+ const heading = title?.trim();
+ return (
+
+ {heading && (
+
+
+ {heading}
+
+ )}
+
+
+ );
+ }
+
+ return ;
+}
+
+export default ScopesDisplay;
diff --git a/app/_components/toolkit-docs/components/ToolSection.tsx b/app/_components/toolkit-docs/components/ToolSection.tsx
new file mode 100644
index 000000000..40080afbe
--- /dev/null
+++ b/app/_components/toolkit-docs/components/ToolSection.tsx
@@ -0,0 +1,412 @@
+"use client";
+
+import { Button } from "@arcadeai/design-system";
+import {
+ Check,
+ Copy,
+ ExternalLink,
+ KeyRound,
+ Lock,
+ ShieldCheck,
+ Wrench,
+} from "lucide-react";
+import { useCallback, useState } from "react";
+
+import { DocumentationChunkRenderer, hasChunksAt } from "./DocumentationChunkRenderer";
+import { DynamicCodeBlock } from "./DynamicCodeBlock";
+import { ParametersTable } from "./ParametersTable";
+import { ScopesDisplay } from "./ScopesDisplay";
+import { toToolAnchorId } from "./AvailableToolsTable";
+import { getDashboardUrl } from "../../dashboard-link";
+import { useOrySession } from "../../../_lib/ory-session-context";
+import type { ToolSectionProps } from "../types";
+
+function CopyToolButton({ tool }: { tool: ToolSectionProps["tool"] }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = useCallback(async () => {
+ const toolDefinition = {
+ name: tool.qualifiedName,
+ description: tool.description,
+ parameters: tool.parameters.map((p) => ({
+ name: p.name,
+ type: p.type,
+ required: p.required,
+ description: p.description,
+ ...(p.enum ? { enum: p.enum } : {}),
+ })),
+ scopes: tool.auth?.scopes ?? [],
+ secrets: tool.secrets,
+ output: tool.output,
+ };
+
+ try {
+ await navigator.clipboard.writeText(JSON.stringify(toolDefinition, null, 2));
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ console.error("Failed to copy tool definition");
+ }
+ }, [tool]);
+
+ return (
+
+ );
+}
+
+function CopyScopesButton({ scopes }: { scopes: string[] }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(scopes.join("\n"));
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ console.error("Failed to copy scopes");
+ }
+ }, [scopes]);
+
+ return (
+
+ );
+}
+
+export function shouldRenderDefaultSection(
+ chunks: ToolSectionProps["tool"]["documentationChunks"],
+ location: "description" | "parameters" | "auth" | "secrets" | "output"
+): boolean {
+ return !hasChunksAt(chunks, location, "replace");
+}
+
+/**
+ * ToolSection
+ *
+ * Renders a single tool section with parameters, scopes, secrets, output, and example.
+ */
+export function ToolSection({
+ tool,
+ isSelected = false,
+ showSelection = false,
+ onToggleSelection,
+}: ToolSectionProps) {
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const anchorId = toToolAnchorId(tool.qualifiedName);
+ const scopes = tool.auth?.scopes ?? [];
+ const secretsInfo = tool.secretsInfo ?? [];
+ const hasScopes = scopes.length > 0;
+ const hasSecrets =
+ (tool.secrets?.length ?? 0) > 0 || (tool.secretsInfo?.length ?? 0) > 0;
+
+ const showDescription = shouldRenderDefaultSection(
+ tool.documentationChunks,
+ "description"
+ );
+ const showParameters = shouldRenderDefaultSection(
+ tool.documentationChunks,
+ "parameters"
+ );
+ const showAuth = shouldRenderDefaultSection(tool.documentationChunks, "auth");
+ const showSecrets = shouldRenderDefaultSection(
+ tool.documentationChunks,
+ "secrets"
+ );
+ const showOutput = shouldRenderDefaultSection(tool.documentationChunks, "output");
+
+ const { email, loading } = useOrySession();
+ const isLoggedIn = !loading && !!email;
+ const loginUrl = getDashboardUrl();
+ const executeUrl = getDashboardUrl(
+ `playground/execute?toolId=${encodeURIComponent(tool.qualifiedName)}`
+ );
+
+ const getActionHref = (href: string) => (isLoggedIn ? href : loginUrl);
+ const getActionTitle = (label: string) =>
+ isLoggedIn ? label : "Sign in to use this in the Arcade dashboard";
+
+ const actionHint = isLoggedIn
+ ? "Run this tool directly in the Arcade dashboard."
+ : "Sign in to run tools. You'll be redirected to the dashboard login.";
+
+ return (
+
+ {/* Tool Header */}
+
+
+
+
+
+
+ {tool.qualifiedName}
+
+
+
+
+ {showSelection && (
+
+ )}
+
+
+
+ {/* Description */}
+
+ {showDescription && (
+
+ {tool.description ?? "No description provided."}
+
+ )}
+
+
+
+ {/* Parameters */}
+
+
+ Parameters
+
+
+ {showParameters &&
}
+
+
+
+
+ {/* Requirements Summary */}
+
+
+ Requirements
+
+
+ {/* Scopes indicator - only shown when advanced is enabled and there are scopes */}
+ {showAdvanced && hasScopes && (
+
+
+
+ Requires {scopes.length} OAuth scope{scopes.length > 1 ? "s" : ""}
+
+
+ )}
+
+ {/* Secrets - always show the actual secret names */}
+
+
+ {hasSecrets ? (
+
+ Secrets:
+ {secretsInfo.length > 0
+ ? secretsInfo.map((secret) => (
+
+ {secret.name}
+ ({secret.type})
+
+ ))
+ : tool.secrets.map((secret) => (
+
+ {secret}
+
+ ))}
+
+ ) : (
+
No secrets required
+ )}
+
+
+ {/* Show OAuth Scopes Toggle - inside the box, only when there are scopes */}
+ {hasScopes && (
+
+
+
+ )}
+
+
+ {/* OAuth Scopes Details - only shown when advanced mode is enabled and there are scopes */}
+ {showAdvanced && hasScopes && (
+
+
+
+
+ Required OAuth scopes
+
+
+
+
+ {showAuth &&
}
+
+
+
+ )}
+
+ {/* Output */}
+
+
+ Output
+
+
+ {showOutput && (
+
+ {tool.output ? (
+
+ Type:
+
+ {tool.output.type}
+
+ {tool.output.description && (
+ — {tool.output.description}
+ )}
+
+ ) : (
+
No output schema provided.
+ )}
+
+ )}
+
+
+
+
+ {/* Try it in Arcade */}
+
+
+ {/* Code Example */}
+ {tool.codeExample ? (
+
+
+
+ ) : (
+
+ No code example available for this tool.
+
+ )}
+
+ );
+}
+
+export default ToolSection;
diff --git a/app/_components/toolkit-docs/components/ToolkitDocsPreview.tsx b/app/_components/toolkit-docs/components/ToolkitDocsPreview.tsx
new file mode 100644
index 000000000..29cbe4e43
--- /dev/null
+++ b/app/_components/toolkit-docs/components/ToolkitDocsPreview.tsx
@@ -0,0 +1,25 @@
+import type { ToolkitData } from "../types";
+import { readToolkitData } from "@/app/_lib/toolkit-data";
+import { ToolkitPage } from "./ToolkitPage";
+
+type ToolkitDocsPreviewProps = {
+ toolkitId: string;
+ fallbackData?: ToolkitData;
+};
+
+export async function ToolkitDocsPreview({
+ toolkitId,
+ fallbackData,
+}: ToolkitDocsPreviewProps) {
+ const toolkitData = (await readToolkitData(toolkitId)) ?? fallbackData;
+
+ if (!toolkitData) {
+ return (
+
+ Toolkit data not found for {toolkitId}.
+
+ );
+ }
+
+ return ;
+}
diff --git a/app/_components/toolkit-docs/components/ToolkitHeader.tsx b/app/_components/toolkit-docs/components/ToolkitHeader.tsx
new file mode 100644
index 000000000..37bf1fb15
--- /dev/null
+++ b/app/_components/toolkit-docs/components/ToolkitHeader.tsx
@@ -0,0 +1,285 @@
+"use client";
+
+import {
+ Badge,
+ ByocBadge,
+ getToolkitIconByName,
+ ProBadge,
+} from "@arcadeai/design-system";
+import { KeyRound, Wrench } from "lucide-react";
+import type React from "react";
+
+import { TYPE_CONFIG } from "../../../en/resources/integrations/components/type-config";
+import {
+ AUTH_TYPE_LABELS,
+ DEFAULT_AUTHOR,
+ getAuthProviderDocsUrl,
+ getGitHubRepoUrl,
+ getPackageName,
+ getPyPIUrl,
+ LICENSE_BADGE,
+ PYPI_BADGES,
+} from "../constants";
+import type { ToolkitHeaderProps, ToolkitType } from "../types";
+
+/**
+ * Renders toolkit type, BYOC, and Pro badges
+ */
+function ToolkitBadges({
+ type,
+ isByoc,
+ isPro,
+}: {
+ type: ToolkitType;
+ isByoc: boolean;
+ isPro: boolean;
+}) {
+ const typeInfo = type !== "auth" ? TYPE_CONFIG[type] : null;
+ const TypeIcon = typeInfo?.icon;
+
+ const showBadges = isPro || isByoc || typeInfo;
+
+ if (!showBadges) {
+ return null;
+ }
+
+ return (
+
+ {typeInfo && TypeIcon && (
+
+
+ {typeInfo.label}
+
+ )}
+ {isByoc &&
}
+ {isPro &&
}
+
+ );
+}
+
+/**
+ * ToolkitHeader
+ *
+ * Renders the header section for a toolkit documentation page, including:
+ * - Toolkit icon (from Design System)
+ * - Toolkit name badges (type, BYOC, Pro)
+ * - Description
+ * - Author
+ * - Auth provider link
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function ToolkitHeader({
+ id,
+ label,
+ description,
+ metadata,
+ auth,
+ version,
+ author = DEFAULT_AUTHOR,
+ toolStats,
+}: ToolkitHeaderProps): React.ReactElement {
+ // Get the icon component from Design System
+ const IconComponent = getToolkitIconByName(label);
+ const iconUrl = metadata.iconUrl;
+ const packageName = getPackageName(id);
+
+ // Determine auth display
+ const authProviderName = auth?.providerId
+ ? auth.providerId.charAt(0).toUpperCase() + auth.providerId.slice(1)
+ : null;
+ const authDocsUrl = auth?.providerId
+ ? getAuthProviderDocsUrl(auth.providerId)
+ : null;
+ const authProviderLink =
+ authProviderName && authDocsUrl ? (
+
+ {authProviderName} auth provider
+
+ ) : null;
+
+ return (
+
+
+ {/* Icon - centered vertically */}
+ {IconComponent ? (
+
+ ) : iconUrl ? (
+
+
+

+
+
+ ) : null}
+
+ {/* Content */}
+
+ {/* Badges */}
+
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Info Grid */}
+
+ {/* Author */}
+
+ Author:
+ {author}
+
+
+ {/* Version */}
+ {version && (
+
+ Version:
+
+ {version}
+
+
+ )}
+
+ {/* Code link */}
+ {id && (
+
+ )}
+
+ {/* Auth info */}
+
+ Auth:
+
+ {auth?.type === "oauth2" ? (
+ <>
+ {AUTH_TYPE_LABELS.oauth2}
+ {authProviderLink && <> via the {authProviderLink}>}
+ >
+ ) : auth?.type === "api_key" ? (
+ AUTH_TYPE_LABELS.api_key
+ ) : auth?.type === "mixed" ? (
+ <>
+ {AUTH_TYPE_LABELS.mixed}
+ {authProviderLink && <> via the {authProviderLink}>} {AUTH_TYPE_LABELS.mixedSuffix}
+ >
+ ) : (
+ AUTH_TYPE_LABELS.none
+ )}
+
+
+
+
+ {/* Tool Stats */}
+ {toolStats && (
+
+ {/* Total tools */}
+
+
+
+
+
+ {toolStats.total}
+ tools
+
+
+
+ {/* Tools with secrets */}
+ {toolStats.withSecrets > 0 && (
+
+
+
+
+
+ {toolStats.withSecrets}
+ require secrets
+
+
+ )}
+
+ )}
+
+
+
+ {/* PyPI Badges */}
+ {id && (
+
+
+ {PYPI_BADGES.map((badge) => (
+
+
+
+ ))}
+
+
+
+
+
+ )}
+
+ );
+}
+
+export default ToolkitHeader;
diff --git a/app/_components/toolkit-docs/components/ToolkitPage.tsx b/app/_components/toolkit-docs/components/ToolkitPage.tsx
new file mode 100644
index 000000000..4aeb53370
--- /dev/null
+++ b/app/_components/toolkit-docs/components/ToolkitPage.tsx
@@ -0,0 +1,369 @@
+"use client";
+
+import { KeyRound } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import ReactMarkdown from "react-markdown";
+
+import ScopePicker from "../../scope-picker";
+import ToolFooter from "../../tool-footer";
+import { AvailableToolsTable, toToolAnchorId } from "./AvailableToolsTable";
+import { DocumentationChunkRenderer, hasChunksAt } from "./DocumentationChunkRenderer";
+import { ToolkitHeader } from "./ToolkitHeader";
+import { ToolSection } from "./ToolSection";
+import type { ToolkitPageProps, ToolDefinition, ToolkitCategory } from "../types";
+
+export function buildPipPackageName(toolkitId: string): string {
+ const normalized = toolkitId.toLowerCase().replace(/[^a-z0-9]+/g, "_");
+ return `arcade_${normalized}`;
+}
+
+function toTitleCaseCategory(category: ToolkitCategory): string {
+ return category
+ .split("-")
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+/**
+ * Breadcrumb bar like older integration pages.
+ *
+ * Note: preview pages are dynamic, so we render this in-page.
+ */
+function BreadcrumbBar({
+ label,
+ category,
+}: {
+ label: string;
+ category: ToolkitCategory;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * Right sidebar that lists the tools on the page.
+ * Kept fixed and flush to the window edge, without narrowing main content.
+ * Auto-scrolls and highlights the currently visible section.
+ */
+function ToolsOnThisPage({ tools }: { tools: ToolDefinition[] }) {
+ const [activeId, setActiveId] = useState(null);
+ const sidebarRef = useRef(null);
+ const itemRefs = useRef