From 70451b44ce002f6bc5135a196a64525a918a722c Mon Sep 17 00:00:00 2001
From: Teal Larson
Date: Mon, 13 Apr 2026 15:30:42 -0400
Subject: [PATCH 01/13] Add MCP elicitation support for URL-mode auth flows
Enables all three agent templates (ai-sdk, mastra, langchain) to handle
MCP elicitation requests from the Arcade Gateway. When a tool requires
OAuth authorization, the gateway sends an elicitation/create request with
a URL; the agent streams it to the frontend as an "elicitation" event,
which the existing auth-prompt UI displays.
Each template uses its framework's native elicitation API:
- ai-sdk: @ai-sdk/mcp ElicitationRequestSchema + onElicitationRequest
- mastra: @mastra/mcp MCPClient.elicitation.onRequest + Agent.stream
- langchain: mcp SDK Callbacks(on_elicitation=...) + ElicitResult
The existing extractAuthUrlFromToolOutput remains as a fallback for
gateways that don't support the elicitation primitive.
Note: page.tsx includes some pre-existing dashboard UX changes
(dismissible callouts) alongside the onElicitation callback wiring.
Made-with: Cursor
---
.../_shared/nextjs-ui/app/dashboard/page.tsx | 82 ++++-
.../components/dashboard/auth-prompt.tsx | 36 ++-
.../nextjs-ui/hooks/use-plan-stream.ts | 8 +
templates/_shared/nextjs-ui/types/inbox.ts | 1 +
.../partials/arcade-oauth-provider.hbs | 54 +++-
.../_shared/partials/extract-auth-url.hbs | 1 +
.../_shared/partials/plan-route-body.hbs | 284 +++++++++++++++---
templates/ai-sdk/package.json | 4 +-
templates/langchain/app/arcade_oauth.py | 31 +-
templates/langchain/app/routes/plan.py | 38 ++-
templates/langchain/requirements.txt | 4 +-
templates/mastra/app/api/plan/route.ts.hbs | 4 +-
templates/mastra/package.json | 10 +-
13 files changed, 484 insertions(+), 73 deletions(-)
diff --git a/templates/_shared/nextjs-ui/app/dashboard/page.tsx b/templates/_shared/nextjs-ui/app/dashboard/page.tsx
index 7a28304..792f07c 100644
--- a/templates/_shared/nextjs-ui/app/dashboard/page.tsx
+++ b/templates/_shared/nextjs-ui/app/dashboard/page.tsx
@@ -14,6 +14,9 @@ import { useArcadeConnection } from "@/hooks/use-arcade-connection";
import { useSourceCheck } from "@/hooks/use-source-check";
import { usePlanStream } from "@/hooks/use-plan-stream";
import {
+ Alert,
+ AlertTitle,
+ AlertDescription,
Skeleton,
Button,
Card,
@@ -22,7 +25,7 @@ import {
CardHeader,
CardTitle,
} from "@arcadeai/design-system";
-import { Loader2, ShieldAlert, AlertTriangle, RotateCcw } from "lucide-react";
+import { Info, Loader2, ShieldAlert, AlertTriangle, RotateCcw, X } from "lucide-react";
// --- Config health warnings ---
@@ -124,12 +127,31 @@ function DashboardContent() {
if (toolName) markSourceAuthRequired(toolName);
addAuthUrl(url, toolName);
},
+ onElicitation: (_elicitationId, authUrl, message) => {
+ addAuthUrl(authUrl, message);
+ },
onSourcesDone: markAllCheckingAsConnected,
});
// --- Config health check ---
const [configWarnings, setConfigWarnings] = useState([]);
+ // --- Dismissible callout state ---
+ const [showAgentWorkingCallout, setShowAgentWorkingCallout] = useState(() =>
+ typeof window !== "undefined" ? localStorage.getItem("callout-agent-working") !== "1" : true
+ );
+ const dismissAgentWorkingCallout = () => {
+ localStorage.setItem("callout-agent-working", "1");
+ setShowAgentWorkingCallout(false);
+ };
+ const [showCustomizeCallout, setShowCustomizeCallout] = useState(() =>
+ typeof window !== "undefined" ? localStorage.getItem("callout-customize-agent") !== "1" : true
+ );
+ const dismissCustomizeCallout = () => {
+ localStorage.setItem("callout-customize-agent", "1");
+ setShowCustomizeCallout(false);
+ };
+
useEffect(() => {
fetch("/api/health")
.then((r) => r.json())
@@ -209,6 +231,18 @@ function DashboardContent() {
>
I've already signed in — retry
+
+
+ Why Arcade?
+
+ The agent uses Arcade as an MCP Gateway to read from your tools on your behalf.
+ Signing in here links your Arcade identity so the gateway knows which
+ user's tools to access. Your{" "}
+ ARCADE_API_KEY in{" "}
+ .env handles the
+ server-side connection.
+
+
)}
@@ -313,6 +347,29 @@ function DashboardContent() {
{loading && !hasItems && authUrls.length === 0 && (
+ {showAgentWorkingCallout && (
+
+
+
+ What's happening
+
+
+
+ The agent is reading from your connected sources and classifying what it finds.
+ This behavior is driven by the system prompt in{" "}
+
+ app/api/plan/route.ts
+ {" "}
+ — edit it to change what gets fetched, how items are prioritized, or what the
+ agent focuses on.
+
+
+ )}
+ {showCustomizeCallout && (
+
+
+
+ Make it yours
+
+
+
+ Start with the system prompt in{" "}
+
+ app/api/plan/route.ts
+ {" "}
+ — that's where the agent's behavior is defined. From there, check out{" "}
+ AGENT_PLAYBOOK.md{" "}
+ for a full walkthrough of customization points.
+
+
+ )}
Authorization required
-
- The tool {toolName} needs permission to continue.
-
-
-
-
-
+
{toolName} needs permission to continue.
+ {clicked ? (
+
+
+ Waiting for authorization… the agent will resume automatically.
+
+ ) : (
+
+
+
+
+ )}
);
}
diff --git a/templates/_shared/nextjs-ui/hooks/use-plan-stream.ts b/templates/_shared/nextjs-ui/hooks/use-plan-stream.ts
index cb659fc..7761119 100644
--- a/templates/_shared/nextjs-ui/hooks/use-plan-stream.ts
+++ b/templates/_shared/nextjs-ui/hooks/use-plan-stream.ts
@@ -5,6 +5,7 @@ import type { InboxItem, PlanEvent } from "@/types/inbox";
interface PlanStreamCallbacks {
onAuthRequired?: (authUrl: string, toolName?: string) => void;
+ onElicitation?: (elicitationId: string, authUrl: string, message: string) => void;
onSourcesDone?: () => void;
}
@@ -92,6 +93,13 @@ export function usePlanStream(callbacks?: PlanStreamCallbacks): {
case "auth_required":
callbacksRef.current?.onAuthRequired?.(event.authUrl, event.toolName);
break;
+ case "elicitation":
+ callbacksRef.current?.onElicitation?.(
+ event.elicitationId,
+ event.authUrl,
+ event.message
+ );
+ break;
case "status":
setStatusMessage(event.message);
break;
diff --git a/templates/_shared/nextjs-ui/types/inbox.ts b/templates/_shared/nextjs-ui/types/inbox.ts
index 9828ded..8cbe2f4 100644
--- a/templates/_shared/nextjs-ui/types/inbox.ts
+++ b/templates/_shared/nextjs-ui/types/inbox.ts
@@ -31,6 +31,7 @@ export type PlanEvent =
| { type: "task"; data: InboxItem }
| { type: "summary"; data: { total: number; bySource: Record } }
| { type: "auth_required"; authUrl: string; toolName?: string }
+ | { type: "elicitation"; elicitationId: string; authUrl: string; message: string }
| { type: "sources"; sources: string[] }
| { type: "error"; message: string }
| { type: "done" };
diff --git a/templates/_shared/partials/arcade-oauth-provider.hbs b/templates/_shared/partials/arcade-oauth-provider.hbs
index 4d9522c..928902e 100644
--- a/templates/_shared/partials/arcade-oauth-provider.hbs
+++ b/templates/_shared/partials/arcade-oauth-provider.hbs
@@ -1,7 +1,7 @@
{{#if (eq name "mastra")}}
import { MCPClient } from "@mastra/mcp";
{{/if}}
-import { createMCPClient } from "@ai-sdk/mcp";
+import { createMCPClient, ElicitationRequestSchema } from "@ai-sdk/mcp";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import type {
@@ -175,23 +175,71 @@ export const mcpClient = new MCPClient({
arcade: {
url: new URL(getGatewayUrl()),
authProvider: oauthProvider,
+ capabilities: { elicitation: {} },
},
},
});
+// Per-request elicitation bridge: the singleton MCPClient handles elicitation
+// at the module level, but the NDJSON emit() function is per-request.
+let _elicitationCallback: ((params: {
+ elicitationId: string;
+ message: string;
+ authUrl: string;
+}) => void) | null = null;
+
+export function setElicitationCallback(
+ cb: typeof _elicitationCallback
+) {
+ _elicitationCallback = cb;
+}
+
+mcpClient.elicitation.onRequest("arcade", async (request) => {
+ const params = request as Record;
+ const message = (params.message as string) ?? "Authorization required";
+ const authUrl = (params.url as string) ?? "";
+ const elicitationId = (params.elicitationId as string) ?? "";
+ console.log(`[MCP] Elicitation request: ${message}`);
+ _elicitationCallback?.({ elicitationId, message, authUrl });
+ return { action: "accept" as const };
+});
+
{{/if}}
/**
* Create an AI SDK MCP client for Arcade Gateway using stored OAuth tokens.
* Auto-detects transport: SSE for /sse URLs, Streamable HTTP otherwise.
+ *
+ * Pass `onElicitation` to receive URL-mode elicitation events (e.g. OAuth
+ * authorization prompts from the gateway). The gateway blocks the tool call
+ * until the user completes auth, then resumes automatically.
*/
-export async function getArcadeMCPClient() {
+export async function getArcadeMCPClient(options?: {
+ onElicitation?: (params: {
+ elicitationId: string;
+ message: string;
+ authUrl: string;
+ }) => void;
+}) {
const gatewayUrl = getGatewayUrl();
const tokens = oauthProvider.tokens();
const headers = tokens?.access_token
? { Authorization: `Bearer ${tokens.access_token}` }
: undefined;
const transportType = gatewayUrl.endsWith("/sse") ? "sse" : "http";
- return createMCPClient({
+ const client = await createMCPClient({
transport: { type: transportType, url: gatewayUrl, headers },
+ capabilities: { elicitation: {} },
});
+
+ client.onElicitationRequest(ElicitationRequestSchema, async (request) => {
+ const params = request.params as Record;
+ const message = (params.message as string) ?? "Authorization required";
+ const authUrl = (params.url as string) ?? "";
+ const elicitationId = (params.elicitationId as string) ?? "";
+ console.log(`[MCP] Elicitation request: ${message}`);
+ options?.onElicitation?.({ elicitationId, message, authUrl });
+ return { action: "accept" as const };
+ });
+
+ return client;
}
diff --git a/templates/_shared/partials/extract-auth-url.hbs b/templates/_shared/partials/extract-auth-url.hbs
index 5335161..211089f 100644
--- a/templates/_shared/partials/extract-auth-url.hbs
+++ b/templates/_shared/partials/extract-auth-url.hbs
@@ -1,3 +1,4 @@
+// Fallback: extract auth URL from tool output for gateways that don't support elicitation
function extractAuthUrlFromToolOutput(output: unknown): string | null {
const looksLikeAuthUrl = (value: unknown): value is string =>
typeof value === "string" &&
diff --git a/templates/_shared/partials/plan-route-body.hbs b/templates/_shared/partials/plan-route-body.hbs
index 0a83672..eae19eb 100644
--- a/templates/_shared/partials/plan-route-body.hbs
+++ b/templates/_shared/partials/plan-route-body.hbs
@@ -7,6 +7,7 @@ type PlanEvent =
| { type: "task"; data: InboxItem }
| { type: "summary"; data: { total: number; bySource: Record } }
| { type: "auth_required"; authUrl: string; toolName?: string }
+ | { type: "elicitation"; elicitationId: string; authUrl: string; message: string }
| { type: "sources"; sources: string[] }
| { type: "error"; message: string }
| { type: "done" };
@@ -144,15 +145,49 @@ function extractJsonBlocks(text: string): {
};
}
+// --- Shared helpers for tool filtering & truncation ---
+
+const MUTATION = /create|update|delete|send|reply|post|archive|remove|add|invite|merge|close|assign|edit|publish|comment/i;
+
+function isTriageTool(name: string): boolean {
+ return !MUTATION.test(name);
+}
+
+const MAX_TOOL_RESULT_CHARS = 4000;
+
+function truncateToolResult(result: unknown): unknown {
+ if (result && typeof result === "object" && "content" in (result as Record)) {
+ const obj = result as { content: { type: string; text: string }[] };
+ return {
+ ...obj,
+ content: obj.content.map((item) => {
+ if (item.type === "text" && item.text && item.text.length > MAX_TOOL_RESULT_CHARS) {
+ return {
+ ...item,
+ text: item.text.slice(0, MAX_TOOL_RESULT_CHARS) +
+ `\n...[truncated ${item.text.length - MAX_TOOL_RESULT_CHARS} chars]`,
+ };
+ }
+ return item;
+ }),
+ };
+ }
+ if (typeof result === "string" && result.length > MAX_TOOL_RESULT_CHARS) {
+ return result.slice(0, MAX_TOOL_RESULT_CHARS) +
+ `\n...[truncated ${result.length - MAX_TOOL_RESULT_CHARS} chars]`;
+ }
+ return result;
+}
+
// --- Route handler ---
+{{#if (eq name "mastra")}}
export async function POST() {
const user = await getSession();
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
- let mcpClient: Awaited> | null = null;
let streamController: ReadableStreamDefaultController | undefined;
const stream = new ReadableStream({
@@ -169,55 +204,233 @@ export async function POST() {
}
}
- // Process async — return stream immediately
(async () => {
try {
emit({ type: "status", message: "Connecting to Arcade Gateway..." });
- mcpClient = await getArcadeMCPClient();
- const allTools = await mcpClient.tools();
+ setElicitationCallback(({ elicitationId, message, authUrl }) => {
+ emit({ type: "elicitation", elicitationId, authUrl, message });
+ });
- // Only keep tools useful for triage — filter out mutations (create, delete, send, etc.)
- // so the agent only reads data during planning.
- const MUTATION = /create|update|delete|send|reply|post|archive|remove|add|invite|merge|close|assign|edit|publish|comment/i;
+ const allToolsets = await mcpClient.listToolsets();
+
+ // Filter toolsets: keep only read-only triage tools, wrap execute for truncation.
+ // listToolsets() returns { serverName: { toolName: tool, ... } }.
+ const filteredToolsets: Record> = {};
+ const toolNames: string[] = [];
+ let allToolCount = 0;
+
+ for (const [setName, toolsObj] of Object.entries(allToolsets)) {
+ const tools = toolsObj as unknown as Record Promise; [k: string]: unknown }>;
+ const filtered: Record = {};
+
+ for (const [toolName, tool] of Object.entries(tools)) {
+ allToolCount++;
+ if (!isTriageTool(toolName)) continue;
+ if (/[._]WhoAmI$/i.test(toolName)) continue;
+
+ if (tool.execute) {
+ const orig = tool.execute;
+ filtered[toolName] = {
+ ...tool,
+ execute: async (...args: unknown[]) => truncateToolResult(await orig(...args)),
+ };
+ } else {
+ filtered[toolName] = tool;
+ }
+ toolNames.push(toolName);
+ }
- function isTriageTool(name: string): boolean {
- return !MUTATION.test(name);
+ if (Object.keys(filtered).length > 0) {
+ filteredToolsets[setName] = filtered;
+ }
}
- // Cap individual tool results so they don't blow the model's context window.
- // Gmail threads, GitHub notifications, etc. can return 50k+ chars each.
- // MCP tools return { content: [{ type: "text", text: "..." }] } — we must
- // preserve that structure or the AI SDK crashes.
- const MAX_TOOL_RESULT_CHARS = 4000;
-
- function truncateToolResult(result: unknown): unknown {
- if (result && typeof result === "object" && "content" in (result as Record)) {
- const obj = result as { content: { type: string; text: string }[] };
- return {
- ...obj,
- content: obj.content.map((item) => {
- if (item.type === "text" && item.text && item.text.length > MAX_TOOL_RESULT_CHARS) {
- return {
- ...item,
- text: item.text.slice(0, MAX_TOOL_RESULT_CHARS) +
- `\n...[truncated ${item.text.length - MAX_TOOL_RESULT_CHARS} chars]`,
- };
- }
- return item;
- }),
+
+ const sources = [...new Set(toolNames.map((n) => mapToolToSource(n)))];
+ console.log(`[plan] ${toolNames.length} triage tools (of ${allToolCount} total) from sources: ${sources.join(", ")}`);
+
+ emit({
+ type: "status",
+ message: `Found ${toolNames.length} tools across ${sources.length} sources. Starting triage...`,
+ });
+
+ emit({ type: "sources", sources });
+
+ const toolsBySource: Record = {};
+ for (const name of toolNames) {
+ const src = mapToolToSource(name);
+ if (!toolsBySource[src]) toolsBySource[src] = [];
+ toolsBySource[src].push(name);
+ }
+ const toolInventory = Object.entries(toolsBySource)
+ .map(([src, names]) => `${src}: ${names.join(", ")}`)
+ .join("\n");
+
+ const prompt = `Plan my day. Today is ${new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })}.\n\nHere are the tools available by source:\n\n${toolInventory}\n\nIn your FIRST step, call ALL non-WhoAmI tools in parallel — one call per tool. Do NOT skip any source.\nFor calendar tools, make sure to pass today's date range so you get today's events.\nAfter getting results, classify every item. If any source returned a list you can drill into, make follow-up calls.\nI need a COMPLETE picture: notifications, assigned work, upcoming events, and unread messages.`;
+
+ let accumulatedText = "";
+ let emittedTaskCount = 0;
+ let emittedSummary = false;
+
+ const agent = new Agent({
+ id: "plan-agent",
+ name: "Plan Agent",
+ model: getModel(),
+ instructions: PLAN_SYSTEM_PROMPT,
+ });
+
+ const result = await agent.stream(prompt, {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ toolsets: filteredToolsets as any,
+ maxSteps: 20,
+ onStepFinish: (step) => {
+ const { toolCalls = [], toolResults = [] } = step as unknown as {
+ toolCalls: { toolCallId: string; toolName: string }[];
+ toolResults: { toolCallId?: string; output: unknown }[];
};
+ console.log(`[plan] Step: ${toolCalls.length} calls, ${toolResults.length} results`);
+
+ for (const call of toolCalls) {
+ const source = mapToolToSource(call.toolName);
+ emit({
+ type: "status",
+ message: `Calling ${source}: ${call.toolName}...`,
+ });
+ }
+
+ const toolNameByCallId = new Map(
+ toolCalls.map((call) => [call.toolCallId, call.toolName] as const)
+ );
+
+ for (let i = 0; i < toolResults.length; i++) {
+ const tr = toolResults[i];
+ const authUrl = extractAuthUrlFromToolOutput(tr.output);
+ if (authUrl) {
+ const matchedToolName = tr.toolCallId
+ ? toolNameByCallId.get(tr.toolCallId)
+ : undefined;
+ const fallbackToolName = i < toolCalls.length
+ ? toolCalls[i].toolName
+ : undefined;
+ const toolName = mapToolToSource(matchedToolName ?? fallbackToolName ?? "");
+ emit({ type: "auth_required", authUrl, toolName });
+ }
+ }
+ },
+ });
+
+ for await (const chunk of result.textStream) {
+ if (chunk) {
+ accumulatedText += chunk;
+ const { tasks, summary, remaining } = extractJsonBlocks(accumulatedText);
+ accumulatedText = remaining;
+
+ for (const task of tasks) {
+ emittedTaskCount++;
+ emit({ type: "task", data: task });
+ emit({
+ type: "status",
+ message: `Classified ${emittedTaskCount} item${emittedTaskCount > 1 ? "s" : ""}...`,
+ });
+ }
+ if (summary) {
+ emit({ type: "summary", data: summary });
+ emittedSummary = true;
+ }
+ }
+ }
+
+ if (accumulatedText.length > 0) {
+ const { tasks, summary } = extractJsonBlocks(accumulatedText);
+ for (const task of tasks) {
+ emittedTaskCount++;
+ emit({ type: "task", data: task });
}
- if (typeof result === "string" && result.length > MAX_TOOL_RESULT_CHARS) {
- return result.slice(0, MAX_TOOL_RESULT_CHARS) +
- `\n...[truncated ${result.length - MAX_TOOL_RESULT_CHARS} chars]`;
+ if (summary) {
+ emit({ type: "summary", data: summary });
+ emittedSummary = true;
}
- return result;
}
+ if (!emittedSummary && emittedTaskCount > 0) {
+ emit({
+ type: "summary",
+ data: { total: emittedTaskCount, bySource: {} },
+ });
+ }
+
+ emit({ type: "done" });
+ } catch (error) {
+ console.error("[plan] Error:", error);
+ const msg = error instanceof Error ? error.message : "Unknown error";
+ const isAuthError =
+ msg.includes("401") ||
+ msg.includes("Missing Authorization") ||
+ msg.includes("Unauthorized");
+ emit({
+ type: "error",
+ message: isAuthError
+ ? "Not connected to Arcade. Please connect your Arcade account from the dashboard."
+ : msg,
+ });
+ emit({ type: "done" });
+ } finally {
+ setElicitationCallback(null);
+ try {
+ streamController?.close();
+ } catch {
+ /* ignore */
+ }
+ }
+ })();
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "application/x-ndjson",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ },
+ });
+}
+{{else}}
+export async function POST() {
+ const user = await getSession();
+ if (!user) {
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ let mcpClient: Awaited> | null = null;
+ let streamController: ReadableStreamDefaultController | undefined;
+
+ const stream = new ReadableStream({
+ start(controller) {
+ streamController = controller;
+ },
+ });
+
+ function emit(event: PlanEvent) {
+ try {
+ streamController?.enqueue(encodeEvent(event));
+ } catch {
+ // Stream closed by client
+ }
+ }
+
+ // Process async — return stream immediately
+ (async () => {
+ try {
+ emit({ type: "status", message: "Connecting to Arcade Gateway..." });
+
+ mcpClient = await getArcadeMCPClient({
+ onElicitation: ({ elicitationId, message, authUrl }) => {
+ emit({ type: "elicitation", elicitationId, authUrl, message });
+ },
+ });
+ const allTools = await mcpClient.tools();
+
const tools: typeof allTools = {};
for (const [name, tool] of Object.entries(allTools)) {
if (!isTriageTool(name)) continue;
- // Don't give WhoAmI tools to the model — they're for auth checks, not data
if (/[._]WhoAmI$/i.test(name)) continue;
const orig = tool.execute;
tools[name] = {
@@ -389,3 +602,4 @@ export async function POST() {
},
});
}
+{{/if}}
diff --git a/templates/ai-sdk/package.json b/templates/ai-sdk/package.json
index f1629bd..6cc2e23 100644
--- a/templates/ai-sdk/package.json
+++ b/templates/ai-sdk/package.json
@@ -14,10 +14,10 @@
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.0",
- "@ai-sdk/mcp": "^1.0.21",
+ "@ai-sdk/mcp": "^1.0.36",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/react": "^3.0.92",
- "@modelcontextprotocol/sdk": "^1.12.1",
+ "@modelcontextprotocol/sdk": "^1.27.1",
"ai": "^6.0.90",
"@better-auth/drizzle-adapter": "^1.5.3",
"better-auth": "^1.5.3",
diff --git a/templates/langchain/app/arcade_oauth.py b/templates/langchain/app/arcade_oauth.py
index 8885cbd..2b4f881 100644
--- a/templates/langchain/app/arcade_oauth.py
+++ b/templates/langchain/app/arcade_oauth.py
@@ -16,6 +16,7 @@
import logging
import secrets
import time
+from collections.abc import Callable
from pathlib import Path
from urllib.parse import urlencode, urlparse
@@ -358,6 +359,32 @@ async def exchange_code(code: str) -> dict:
return tokens
+# --- Per-request elicitation bridge ---
+# The MCP client is a module-level singleton, but the NDJSON emitter is
+# per-request. A module-level callback ref bridges the two.
+
+_elicitation_callback: Callable[[str, str, str], None] | None = None
+
+
+def set_elicitation_callback(cb: Callable[[str, str, str], None] | None):
+ """Set (or clear) the per-request elicitation event callback."""
+ global _elicitation_callback
+ _elicitation_callback = cb
+
+
+async def _on_elicitation(mcp_context, params, context):
+ """Handle URL-mode elicitation requests from Arcade gateway."""
+ from mcp.types import ElicitResult
+
+ message = getattr(params, "message", "Authorization required")
+ auth_url = getattr(params, "url", "")
+ elicitation_id = getattr(params, "elicitation_id", "") or getattr(params, "elicitationId", "")
+ logging.getLogger(__name__).info("[MCP] Elicitation request: %s", message)
+ if _elicitation_callback:
+ _elicitation_callback(elicitation_id, message, auth_url)
+ return ElicitResult(action="accept")
+
+
# --- MCP Client factory ---
@@ -367,6 +394,7 @@ def create_mcp_client():
After OAuth is complete, the stored access_token is sent as a
Bearer token to authenticate with the Arcade MCP Gateway.
"""
+ from langchain_mcp_adapters.callbacks import Callbacks
from langchain_mcp_adapters.client import MultiServerMCPClient
if not settings.arcade_gateway_url:
@@ -387,7 +415,8 @@ def create_mcp_client():
"url": settings.arcade_gateway_url,
"headers": headers,
}
- }
+ },
+ callbacks=Callbacks(on_elicitation=_on_elicitation),
)
diff --git a/templates/langchain/app/routes/plan.py b/templates/langchain/app/routes/plan.py
index ef92375..132b638 100644
--- a/templates/langchain/app/routes/plan.py
+++ b/templates/langchain/app/routes/plan.py
@@ -17,7 +17,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.agent import get_llm
-from app.arcade_oauth import get_cached_tools, get_mcp_client
+from app.arcade_oauth import get_cached_tools, get_mcp_client, set_elicitation_callback
from app.auth import get_current_user
from app.database import get_db
@@ -110,15 +110,15 @@ def _build_plan_prompt():
"```\n\n"
"URL RULES:\n"
"Prefer a direct deep link to the item itself:\n"
- "- Slack: use the \"permalink\" field if present"
+ '- Slack: use the "permalink" field if present'
" (https://.slack.com/archives//p)\n"
"- GitHub: use the issue or PR URL on github.com\n"
"- Linear: use the Linear issue URL\n"
"- Gmail: use the Gmail thread URL (https://mail.google.com/mail/u/0/#inbox/)\n"
- "- Google Calendar: use the \"htmlLink\" field if present\n"
+ '- Google Calendar: use the "htmlLink" field if present\n'
"If no direct deep link is available, fall back to the most relevant URL"
" found anywhere in the tool response.\n"
- "Only omit \"url\" if there is truly no URL available in the response at all.\n\n"
+ 'Only omit "url" if there is truly no URL available in the response at all.\n\n'
"Rules:\n"
"- One json:task block per ACTIONABLE item (skip empty results, metadata, and errors)\n"
"- Brief status text between blocks is fine\n"
@@ -140,7 +140,7 @@ def _patch_tool_schemas(tools):
def _extract_auth_url(content) -> str | None:
- """Check if a tool result contains an Arcade authorization URL."""
+ """Fallback: extract auth URL from tool output for gateways that don't support elicitation."""
if not isinstance(content, str):
return None
try:
@@ -206,6 +206,16 @@ async def stream():
yield _encode_event({"type": "status", "message": "Connecting to Arcade Gateway..."})
+ # Wire per-request elicitation events to the NDJSON stream.
+ # The elicitation callback fires during tool execution; we buffer events
+ # and drain them on each iteration of the stream loop.
+ elicitation_events: list[dict] = []
+ set_elicitation_callback(
+ lambda eid, msg, url: elicitation_events.append(
+ {"type": "elicitation", "elicitationId": eid, "authUrl": url, "message": msg}
+ )
+ )
+
mcp_client = get_mcp_client()
try:
@@ -216,7 +226,8 @@ async def stream():
# Uses pattern matching so it works regardless of Arcade's exact naming
# convention (underscores, dots, or mixed casing are all handled).
_KNOWN_SERVICE = re.compile(
- r"^(github|gmail|google|calendar|linear|slack)", re.IGNORECASE,
+ r"^(github|gmail|google|calendar|linear|slack)",
+ re.IGNORECASE,
)
_MUTATION = re.compile(
r"create|update|delete|send|reply|post|archive|remove"
@@ -228,6 +239,7 @@ def _is_triage_tool(name: str) -> bool:
if not _KNOWN_SERVICE.search(name):
return False
return not _MUTATION.search(name)
+
MAX_TOOL_RESULT_CHARS = 4000
def _wrap_tool(tool):
@@ -246,10 +258,13 @@ async def _truncated_ainvoke(*args, **kwargs):
updates = {"coroutine": _truncated_ainvoke}
if original_func is not None:
+
def _truncated_invoke(*args, **kwargs):
result = original_func(*args, **kwargs)
- s = result if isinstance(result, str) else (
- json.dumps(result) if result else ""
+ s = (
+ result
+ if isinstance(result, str)
+ else (json.dumps(result) if result else "")
)
if len(s) > MAX_TOOL_RESULT_CHARS:
return (
@@ -257,6 +272,7 @@ def _truncated_invoke(*args, **kwargs):
+ f"\n...[truncated {len(s) - MAX_TOOL_RESULT_CHARS} chars]"
)
return result
+
updates["func"] = _truncated_invoke
return tool.model_copy(update=updates)
@@ -325,6 +341,10 @@ def _truncated_invoke(*args, **kwargs):
{"messages": [HumanMessage(content=user_message)]},
stream_mode="updates",
):
+ # Drain any elicitation events that arrived during tool execution
+ while elicitation_events:
+ yield _encode_event(elicitation_events.pop(0))
+
for node_name, update in event.items():
# Check for tool results with auth URLs
if node_name == "tools" and "messages" in update:
@@ -413,6 +433,8 @@ def _truncated_invoke(*args, **kwargs):
}
)
yield _encode_event({"type": "done"})
+ finally:
+ set_elicitation_callback(None)
return StreamingResponse(
stream(),
diff --git a/templates/langchain/requirements.txt b/templates/langchain/requirements.txt
index 0c12636..5ed0983 100644
--- a/templates/langchain/requirements.txt
+++ b/templates/langchain/requirements.txt
@@ -1,7 +1,7 @@
langchain-openai>=0.3
langchain-anthropic>=0.3
-langchain-mcp-adapters>=0.2
-mcp>=1.26
+langchain-mcp-adapters>=0.2.2
+mcp>=1.27
langgraph>=0.3
fastapi>=0.115
fastapi-users[sqlalchemy]>=14.0
diff --git a/templates/mastra/app/api/plan/route.ts.hbs b/templates/mastra/app/api/plan/route.ts.hbs
index dd79040..993c6ba 100644
--- a/templates/mastra/app/api/plan/route.ts.hbs
+++ b/templates/mastra/app/api/plan/route.ts.hbs
@@ -8,10 +8,10 @@
* structured InboxItem data as NDJSON.
*/
-import { streamText, stepCountIs } from "ai";
+import { Agent } from "@mastra/core/agent";
import { getSession } from "@/lib/auth";
import { getModel } from "@/src/mastra/agents/triage-agent";
-import { getArcadeMCPClient } from "@/src/mastra/tools/arcade";
+import { mcpClient, setElicitationCallback } from "@/src/mastra/tools/arcade";
import type { InboxItem } from "@/types/inbox";
{{> plan-route-body}}
diff --git a/templates/mastra/package.json b/templates/mastra/package.json
index f117716..a1280a1 100644
--- a/templates/mastra/package.json
+++ b/templates/mastra/package.json
@@ -14,13 +14,13 @@
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.0",
- "@ai-sdk/mcp": "^1.0.21",
+ "@ai-sdk/mcp": "^1.0.36",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/react": "^3.0.92",
- "@mastra/ai-sdk": "^1.0.4",
- "@mastra/core": "^1.4.0",
- "@mastra/mcp": "^1.0.1",
- "@modelcontextprotocol/sdk": "^1.12.1",
+ "@mastra/ai-sdk": "^1.3.3",
+ "@mastra/core": "^1.24.1",
+ "@mastra/mcp": "^1.4.2",
+ "@modelcontextprotocol/sdk": "^1.27.1",
"ai": "^6.0.90",
"@better-auth/drizzle-adapter": "^1.5.3",
"better-auth": "^1.5.3",
From 28aee172011cad9782420e673b59ceaa1c7c833c Mon Sep 17 00:00:00 2001
From: Teal Larson
Date: Mon, 13 Apr 2026 15:59:00 -0400
Subject: [PATCH 02/13] Fix: explicitly declare URL-mode elicitation capability
The MCP spec requires clients to opt into URL-mode elicitation by
including `url: {}` in the capabilities object. Without this, servers
won't send URL-mode requests (which Arcade uses for OAuth flows).
The Python MCP SDK does this automatically when an elicitation callback
is provided, but the TypeScript side (both @ai-sdk/mcp createMCPClient
and @mastra/mcp MCPClient) requires explicit declaration.
Changes `{ elicitation: {} }` to `{ elicitation: { url: {} } }` in both
the Mastra singleton and the AI SDK client factory.
Made-with: Cursor
---
templates/_shared/partials/arcade-oauth-provider.hbs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/templates/_shared/partials/arcade-oauth-provider.hbs b/templates/_shared/partials/arcade-oauth-provider.hbs
index 928902e..9f1707f 100644
--- a/templates/_shared/partials/arcade-oauth-provider.hbs
+++ b/templates/_shared/partials/arcade-oauth-provider.hbs
@@ -175,7 +175,7 @@ export const mcpClient = new MCPClient({
arcade: {
url: new URL(getGatewayUrl()),
authProvider: oauthProvider,
- capabilities: { elicitation: {} },
+ capabilities: { elicitation: { url: {} } },
},
},
});
@@ -228,7 +228,7 @@ export async function getArcadeMCPClient(options?: {
const transportType = gatewayUrl.endsWith("/sse") ? "sse" : "http";
const client = await createMCPClient({
transport: { type: transportType, url: gatewayUrl, headers },
- capabilities: { elicitation: {} },
+ capabilities: { elicitation: { url: {} } },
});
client.onElicitationRequest(ElicitationRequestSchema, async (request) => {
From 999abc191f4980f3d4473a8dfb2c5f924d6b94e1 Mon Sep 17 00:00:00 2001
From: Teal Larson
Date: Tue, 14 Apr 2026 13:34:54 -0400
Subject: [PATCH 03/13] Fix Mastra elicitation bugs: OAuth race, step
reporting, AI SDK auth flow
- Replace authProvider with per-request fetch in Mastra MCPClient so the
singleton never triggers its own OAuth handshake and races with the
connect route's initiateOAuth() (fixes double sign-in)
- Make elicitation.onRequest() lazy via ensureElicitationHandler() to
avoid eager connect() at module load time; call it from plan route
- Fix onStepFinish callback: Mastra step toolCalls/toolResults are
ChunkType objects { type, payload: { toolName, ... } } not flat AI SDK
objects, so access toolName via call.payload.toolName (fixes "Calling
other: undefined")
- Remove URL-mode elicitation from AI SDK getArcadeMCPClient(): @ai-sdk/mcp
only supports form-mode, declaring url capability caused gateway to use
elicitation instead of tool output, breaking extractAuthUrlFromToolOutput
- Rename "Why Arcade?" callout to "Why sign into Arcade?" (Next.js + Python)
- Add dismissible callouts with X/localStorage to empty-state,
source-auth-gate, and LangChain dashboard/login templates
Made-with: Cursor
---
.../_shared/nextjs-ui/app/dashboard/page.tsx | 4 +-
.../components/dashboard/empty-state.tsx | 47 ++++--
.../components/dashboard/source-auth-gate.tsx | 43 ++++--
.../partials/arcade-oauth-provider.hbs | 78 +++++-----
.../_shared/partials/plan-route-body.hbs | 30 ++--
.../langchain/app/templates/dashboard.html | 135 ++++++------------
templates/langchain/app/templates/login.html | 39 +++--
templates/langchain/template.json | 2 +-
templates/mastra/app/api/plan/route.ts.hbs | 2 +-
9 files changed, 197 insertions(+), 183 deletions(-)
diff --git a/templates/_shared/nextjs-ui/app/dashboard/page.tsx b/templates/_shared/nextjs-ui/app/dashboard/page.tsx
index b72a0e5..7467793 100644
--- a/templates/_shared/nextjs-ui/app/dashboard/page.tsx
+++ b/templates/_shared/nextjs-ui/app/dashboard/page.tsx
@@ -178,6 +178,8 @@ function DashboardContent() {
// --- Handlers ---
const handleLogout = useCallback(async () => {
+ // Clear Arcade tokens so the next user who signs in has to re-authenticate.
+ await fetch("/api/auth/arcade/disconnect", { method: "POST" }).catch(() => {});
await authClient.signOut();
router.push("/");
}, [router]);
@@ -233,7 +235,7 @@ function DashboardContent() {
- Why Arcade?
+ Why sign into Arcade?
The agent uses Arcade as an MCP Gateway to read from your tools on your behalf.
Signing in here links your Arcade identity so the gateway knows which
diff --git a/templates/_shared/nextjs-ui/components/dashboard/empty-state.tsx b/templates/_shared/nextjs-ui/components/dashboard/empty-state.tsx
index 941b13a..5592997 100644
--- a/templates/_shared/nextjs-ui/components/dashboard/empty-state.tsx
+++ b/templates/_shared/nextjs-ui/components/dashboard/empty-state.tsx
@@ -1,12 +1,25 @@
-import { Inbox, Info, Loader2 } from "lucide-react";
+"use client";
+
+import { useState } from "react";
+import { Inbox, Info, Loader2, X } from "lucide-react";
import { Alert, AlertTitle, AlertDescription, Button } from "@arcadeai/design-system";
+const CALLOUT_KEY = "callout-how-planning-works";
+
interface EmptyStateProps {
onPlan: () => void;
loading: boolean;
}
export function EmptyState({ onPlan, loading }: EmptyStateProps) {
+ const [showCallout, setShowCallout] = useState(() =>
+ typeof window !== "undefined" ? localStorage.getItem(CALLOUT_KEY) !== "1" : true
+ );
+ const dismissCallout = () => {
+ localStorage.setItem(CALLOUT_KEY, "1");
+ setShowCallout(false);
+ };
+
return (
@@ -19,17 +32,27 @@ export function EmptyState({ onPlan, loading }: EmptyStateProps) {
build your action plan.
-
-
- How this works
-
- When you click “Plan my day”, the agent connects to your Slack, Gmail, Google
- Calendar, Linear, and GitHub through Arcade's MCP Gateway. It will first check if any
- of those need authentication — if so, you'll be prompted to authorize before the run
- starts. Then it reads your recent messages, emails, events, issues, and pull requests, and
- classifies each item by priority and suggests next steps.
-
-
+ {showCallout && (
+
+
+
+ How this works
+
+
+
+ When you click “Plan my day”, the agent connects to your Slack, Gmail,
+ Google Calendar, Linear, and GitHub through Arcade's MCP Gateway. It will first
+ check if any of those need authentication — if so, you'll be prompted to authorize
+ before the run starts. Then it reads your recent messages, emails, events, issues, and
+ pull requests, and classifies each item by priority and suggests next steps.
+
+
+ )}
-
-
- Why authorize?
-
- Each tool connects on your behalf using OAuth — the agent only gets read access to scan
- for items to triage. You can skip any source you don't use, and revoke access anytime
- from your Arcade dashboard.
-
-
+ {showCallout && (
+
+
+
+ Why authorize?
+
+
+
+ Each tool connects on your behalf using OAuth — the agent only gets read access to scan
+ for items to triage. You can skip any source you don't use, and revoke access
+ anytime from your Arcade dashboard.
+
+
+ )}
{Object.entries(sourceStatuses).map(([source, status]) => {
diff --git a/templates/_shared/partials/arcade-oauth-provider.hbs b/templates/_shared/partials/arcade-oauth-provider.hbs
index 9f1707f..a1d504a 100644
--- a/templates/_shared/partials/arcade-oauth-provider.hbs
+++ b/templates/_shared/partials/arcade-oauth-provider.hbs
@@ -1,7 +1,7 @@
{{#if (eq name "mastra")}}
import { MCPClient } from "@mastra/mcp";
{{/if}}
-import { createMCPClient, ElicitationRequestSchema } from "@ai-sdk/mcp";
+import { createMCPClient } from "@ai-sdk/mcp";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import type {
@@ -104,6 +104,14 @@ export function clearPendingAuthUrl() {
}
}
+export function clearArcadeTokens() {
+ try {
+ unlinkSync(TOKENS_FILE);
+ } catch {
+ // Ignore if file doesn't exist.
+ }
+}
+
// --- OAuth provider (implements OAuthClientProvider from MCP SDK) ---
class ArcadeOAuthProvider implements OAuthClientProvider {
@@ -169,12 +177,21 @@ export async function initiateOAuth(): Promise<"AUTHORIZED" | "REDIRECT"> {
}
{{#if (eq name "mastra")}}
-// Mastra MCP client (used by the Mastra agent for chat)
+// Mastra MCP client — uses a per-request fetch to inject bearer tokens instead
+// of authProvider, so the singleton never triggers its own OAuth handshake and
+// races with the connect route's initiateOAuth() call.
export const mcpClient = new MCPClient({
servers: {
arcade: {
url: new URL(getGatewayUrl()),
- authProvider: oauthProvider,
+ fetch: async (url: RequestInfo | URL, init?: RequestInit) => {
+ const tokens = oauthProvider.tokens();
+ const headers = new Headers(init?.headers);
+ if (tokens?.access_token) {
+ headers.set("Authorization", `Bearer ${tokens.access_token}`);
+ }
+ return fetch(url, { ...init, headers });
+ },
capabilities: { elicitation: { url: {} } },
},
},
@@ -194,52 +211,41 @@ export function setElicitationCallback(
_elicitationCallback = cb;
}
-mcpClient.elicitation.onRequest("arcade", async (request) => {
- const params = request as Record;
- const message = (params.message as string) ?? "Authorization required";
- const authUrl = (params.url as string) ?? "";
- const elicitationId = (params.elicitationId as string) ?? "";
- console.log(`[MCP] Elicitation request: ${message}`);
- _elicitationCallback?.({ elicitationId, message, authUrl });
- return { action: "accept" as const };
-});
+// Lazy elicitation registration — called once per process from the plan route.
+// Keeping it lazy avoids an eager connect() at module load time.
+let _elicitationRegistered = false;
+
+export async function ensureElicitationHandler() {
+ if (_elicitationRegistered) return;
+ _elicitationRegistered = true;
+ await mcpClient.elicitation.onRequest("arcade", async (request) => {
+ const params = request as Record;
+ const message = (params.message as string) ?? "Authorization required";
+ const authUrl = (params.url as string) ?? "";
+ const elicitationId = (params.elicitationId as string) ?? "";
+ console.log(`[MCP] Elicitation request: ${message}`);
+ _elicitationCallback?.({ elicitationId, message, authUrl });
+ return { action: "accept" as const };
+ });
+}
{{/if}}
/**
* Create an AI SDK MCP client for Arcade Gateway using stored OAuth tokens.
* Auto-detects transport: SSE for /sse URLs, Streamable HTTP otherwise.
*
- * Pass `onElicitation` to receive URL-mode elicitation events (e.g. OAuth
- * authorization prompts from the gateway). The gateway blocks the tool call
- * until the user completes auth, then resumes automatically.
+ * Auth URLs are surfaced via extractAuthUrlFromToolOutput — URL-mode elicitation
+ * is not declared here because @ai-sdk/mcp only supports form-mode elicitation.
+ * When URL-mode support lands in @ai-sdk/mcp it can be re-added.
*/
-export async function getArcadeMCPClient(options?: {
- onElicitation?: (params: {
- elicitationId: string;
- message: string;
- authUrl: string;
- }) => void;
-}) {
+export async function getArcadeMCPClient() {
const gatewayUrl = getGatewayUrl();
const tokens = oauthProvider.tokens();
const headers = tokens?.access_token
? { Authorization: `Bearer ${tokens.access_token}` }
: undefined;
const transportType = gatewayUrl.endsWith("/sse") ? "sse" : "http";
- const client = await createMCPClient({
+ return createMCPClient({
transport: { type: transportType, url: gatewayUrl, headers },
- capabilities: { elicitation: { url: {} } },
});
-
- client.onElicitationRequest(ElicitationRequestSchema, async (request) => {
- const params = request.params as Record;
- const message = (params.message as string) ?? "Authorization required";
- const authUrl = (params.url as string) ?? "";
- const elicitationId = (params.elicitationId as string) ?? "";
- console.log(`[MCP] Elicitation request: ${message}`);
- options?.onElicitation?.({ elicitationId, message, authUrl });
- return { action: "accept" as const };
- });
-
- return client;
}
diff --git a/templates/_shared/partials/plan-route-body.hbs b/templates/_shared/partials/plan-route-body.hbs
index 04af3cf..60c5595 100644
--- a/templates/_shared/partials/plan-route-body.hbs
+++ b/templates/_shared/partials/plan-route-body.hbs
@@ -131,6 +131,8 @@ export async function POST() {
try {
emit({ type: "status", message: "Connecting to Arcade Gateway..." });
+ await ensureElicitationHandler();
+
setElicitationCallback(({ elicitationId, message, authUrl }) => {
emit({ type: "elicitation", elicitationId, authUrl, message });
});
@@ -207,33 +209,39 @@ export async function POST() {
toolsets: filteredToolsets as any,
maxSteps: 20,
onStepFinish: (step) => {
- const { toolCalls = [], toolResults = [] } = step as unknown as {
- toolCalls: { toolCallId: string; toolName: string }[];
- toolResults: { toolCallId?: string; output: unknown }[];
- };
+ // Mastra's step.toolCalls/toolResults are ChunkType objects:
+ // { type: "tool-call"|"tool-result", payload: { toolCallId, toolName, output, ... } }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const stepData = step as any;
+ const toolCalls: { type: string; payload: { toolCallId: string; toolName: string } }[] =
+ stepData.toolCalls ?? [];
+ const toolResults: { type: string; payload: { toolCallId?: string; toolName?: string; output: unknown } }[] =
+ stepData.toolResults ?? [];
console.log(`[plan] Step: ${toolCalls.length} calls, ${toolResults.length} results`);
for (const call of toolCalls) {
- const source = mapToolToSource(call.toolName);
+ const toolName = call.payload?.toolName;
+ if (!toolName) continue;
+ const source = mapToolToSource(toolName);
emit({
type: "status",
- message: `Calling ${source}: ${call.toolName}...`,
+ message: `Calling ${source}: ${toolName}...`,
});
}
const toolNameByCallId = new Map(
- toolCalls.map((call) => [call.toolCallId, call.toolName] as const)
+ toolCalls.map((call) => [call.payload?.toolCallId, call.payload?.toolName] as const)
);
for (let i = 0; i < toolResults.length; i++) {
const tr = toolResults[i];
- const authUrl = extractAuthUrlFromToolOutput(tr.output);
+ const authUrl = extractAuthUrlFromToolOutput(tr.payload?.output);
if (authUrl) {
- const matchedToolName = tr.toolCallId
- ? toolNameByCallId.get(tr.toolCallId)
+ const matchedToolName = tr.payload?.toolCallId
+ ? toolNameByCallId.get(tr.payload.toolCallId)
: undefined;
const fallbackToolName = i < toolCalls.length
- ? toolCalls[i].toolName
+ ? toolCalls[i].payload?.toolName
: undefined;
const toolName = mapToolToSource(matchedToolName ?? fallbackToolName ?? "");
emit({ type: "auth_required", authUrl, toolName });
diff --git a/templates/langchain/app/templates/dashboard.html b/templates/langchain/app/templates/dashboard.html
index 0d9ba29..c54721d 100644
--- a/templates/langchain/app/templates/dashboard.html
+++ b/templates/langchain/app/templates/dashboard.html
@@ -120,29 +120,17 @@
Connect to Arcade
I've already signed in — retry
-
+
-
+
-
Why Arcade?
-
- The agent uses Arcade as an MCP Gateway to read from your tools on your behalf.
- Signing in here links your Arcade identity so the gateway knows which user's tools
- to access.
-
+
+
Why sign into Arcade?
+
+
+
The agent uses Arcade as an MCP Gateway to read from your tools on your behalf. Signing in here links your Arcade identity so the gateway knows which user's tools to access.
@@ -233,31 +221,17 @@
Ready to triage?
Your agent will scan your inbox, calendar, tasks, and PRs, then prioritize everything
and build your action plan.
-
+
-
+
-
How this works
-
- When you click "Plan my day", the agent connects to your Slack, Gmail, Google
- Calendar, Linear, and GitHub through Arcade's MCP Gateway. It will first check if
- any of those need authentication — if so, you'll be prompted to authorize before
- the run starts. Then it reads your recent messages, emails, events, issues, and
- pull requests, and classifies each item by priority and suggests next steps.
-
+
+
How this works
+
+
+
When you click "Plan my day", the agent connects to your Slack, Gmail, Google Calendar, Linear, and GitHub through Arcade's MCP Gateway. It will first check if any of those need authentication — if so, you'll be prompted to authorize before the run starts. Then it reads your recent messages, emails, events, issues, and pull requests, and classifies each item by priority and suggests next steps.
@@ -271,31 +245,17 @@
Ready to triage?
-
+
-
+
-
What's happening
-
- The agent is reading from your connected sources and classifying what it finds.
- This behavior is driven by the system prompt in
- app/system-prompt.md —
- edit it to change what gets fetched, how items are prioritized, or what the agent
- focuses on.
-
+
+
What's happening
+
+
+
The agent is reading from your connected sources and classifying what it finds. This behavior is driven by the system prompt in app/system-prompt.md — edit it to change what gets fetched, how items are prioritized, or what the agent focuses on.
@@ -330,31 +290,17 @@
Replan my day
-
+
-
+
-
Make it yours
-
- Start with the system prompt in
- app/system-prompt.md —
- that's where the agent's behavior is defined. From there, check out
- AGENT_PLAYBOOK.md for a
- full walkthrough of customization points.
-
+
+
Make it yours
+
+
+
Start with the system prompt in app/system-prompt.md — that's where the agent's behavior is defined. From there, check out AGENT_PLAYBOOK.md for a full walkthrough of customization points.
- This creates a local account for your agent app — it's just for session management and
- stays in your own database. You'll connect your Arcade account on the next screen to
- give the agent access to your tools.
-
+
+
Two separate sign-ins
+
+
+
This creates a local account for your agent app — it's just for session management and stays in your own database. You'll connect your Arcade account on the next screen to give the agent access to your tools.