diff --git a/templates/_shared/nextjs-ui/app/dashboard/page.tsx b/templates/_shared/nextjs-ui/app/dashboard/page.tsx
index b27212d..af456ae 100644
--- a/templates/_shared/nextjs-ui/app/dashboard/page.tsx
+++ b/templates/_shared/nextjs-ui/app/dashboard/page.tsx
@@ -25,7 +25,7 @@ import {
CardHeader,
CardTitle,
} from "@arcadeai/design-system";
-import { Info, Loader2, ShieldAlert, AlertTriangle, RotateCcw } from "lucide-react";
+import { Info, Loader2, ShieldAlert, AlertTriangle, RotateCcw, X } from "lucide-react";
// --- Config health warnings ---
@@ -127,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())
@@ -159,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]);
@@ -206,15 +227,9 @@ 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
@@ -325,19 +340,29 @@ function DashboardContent() {
{loading && !hasItems && authUrls.length === 0 && (
-
-
- 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.
-
-
+ {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.
+
+
+ )}
-
-
- 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.
-
-
+ {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.
+
@@ -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/nextjs-ui/hooks/use-arcade-connection.ts b/templates/_shared/nextjs-ui/hooks/use-arcade-connection.ts
index 829a309..3f5f55c 100644
--- a/templates/_shared/nextjs-ui/hooks/use-arcade-connection.ts
+++ b/templates/_shared/nextjs-ui/hooks/use-arcade-connection.ts
@@ -64,13 +64,17 @@ export function useArcadeConnection(): {
useEffect(() => {
checkConnection();
- const onFocus = () => {
+ const onFocusOrVisible = () => {
if (Date.now() - lastCheckRef.current < 2000) return;
authInProgress.current = false; // User returned from OAuth tab — re-check
checkConnection();
};
- window.addEventListener("focus", onFocus);
- return () => window.removeEventListener("focus", onFocus);
+ window.addEventListener("focus", onFocusOrVisible);
+ document.addEventListener("visibilitychange", onFocusOrVisible);
+ return () => {
+ window.removeEventListener("focus", onFocusOrVisible);
+ document.removeEventListener("visibilitychange", onFocusOrVisible);
+ };
}, [checkConnection]);
const retryConnection = useCallback(() => checkConnection({ isRetry: true }), [checkConnection]);
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/hooks/use-source-check.ts b/templates/_shared/nextjs-ui/hooks/use-source-check.ts
index 682a956..e976908 100644
--- a/templates/_shared/nextjs-ui/hooks/use-source-check.ts
+++ b/templates/_shared/nextjs-ui/hooks/use-source-check.ts
@@ -69,14 +69,24 @@ export function useSourceCheck(options: { enabled: boolean }): {
checkSources();
}, [enabled, checkSources]);
- // Re-check sources when user returns from an auth tab (while gate is active)
+ // Re-check sources while the auth gate is open:
+ // - focus / visibilitychange: immediately when user switches back to this tab
+ // - setInterval: every 3s so auth completion is detected quickly even if the
+ // user stays on the external auth page for a moment before returning
useEffect(() => {
if (!authGateActive) return;
- const onFocus = () => {
- checkSources();
+ const onFocus = () => checkSources();
+ const onVisibilityChange = () => {
+ if (document.visibilityState === "visible") checkSources();
};
window.addEventListener("focus", onFocus);
- return () => window.removeEventListener("focus", onFocus);
+ document.addEventListener("visibilitychange", onVisibilityChange);
+ const interval = setInterval(checkSources, 3000);
+ return () => {
+ window.removeEventListener("focus", onFocus);
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+ clearInterval(interval);
+ };
}, [authGateActive, checkSources]);
const skipSource = (source: string) => {
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..5578d7e 100644
--- a/templates/_shared/partials/arcade-oauth-provider.hbs
+++ b/templates/_shared/partials/arcade-oauth-provider.hbs
@@ -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,38 @@ 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.
+//
+// URL-mode elicitation is declared so the gateway proactively sends auth URLs
+// when a tool needs authorization. The frontend opens these in a new tab
+// (pointing to app.arcade.dev) — this is expected for tool auth and is
+// separate from the initial Arcade gateway connection, which always redirects
+// back to localhost via PKCE.
+
+// Per-request callback bridging the singleton elicitation handler to the
+// active response stream. Safe for single-user local dev use.
+let _elicitationCallback: ((authUrl: string, message: string) => void) | null = null;
+
+export function setElicitationCallback(
+ cb: ((authUrl: string, message: string) => void) | null
+) {
+ _elicitationCallback = cb;
+}
+
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 });
+ },
},
},
});
@@ -183,6 +217,20 @@ export const mcpClient = new MCPClient({
/**
* Create an AI SDK MCP client for Arcade Gateway using stored OAuth tokens.
* Auto-detects transport: SSE for /sse URLs, Streamable HTTP otherwise.
+ *
+ * WHY NO URL-MODE ELICITATION (as of April 2026):
+ * @ai-sdk/mcp only supports form-mode elicitation. Declaring
+ * `capabilities: { elicitation: { url: {} } }` causes the Arcade gateway to
+ * route tool auth through URL elicitation instead of embedding auth URLs in
+ * tool output — but the AI SDK client has no handler for URL-mode requests,
+ * so those requests are silently dropped and tool auth never surfaces to the
+ * user. The Mastra template uses URL-mode elicitation instead via
+ * @mastra/mcp's mcpClient.elicitation.onRequest().
+ *
+ * Revisit when @ai-sdk/mcp adds URL-mode elicitation support.
+ *
+ * In the meantime, auth URLs are surfaced via extractAuthUrlFromToolOutput
+ * in onStepFinish, which scrapes them from tool result text.
*/
export async function getArcadeMCPClient() {
const gatewayUrl = getGatewayUrl();
diff --git a/templates/_shared/partials/extract-auth-url.hbs b/templates/_shared/partials/extract-auth-url.hbs
index 129d6f4..9506c6d 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 fromRecord = (value: unknown): string | null => {
if (!value || typeof value !== "object") return null;
diff --git a/templates/_shared/partials/plan-route-body.hbs b/templates/_shared/partials/plan-route-body.hbs
index 4f74e8a..90ac0f7 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" };
@@ -67,15 +68,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({
@@ -92,55 +127,268 @@ 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();
+ // Register the URL elicitation handler for this request, then connect.
+ // mcpClient.elicitation.onRequest requires the server name as the first
+ // argument and must be awaited — it connects to the server if needed and
+ // registers the handler on the underlying MCP client so the gateway can
+ // send auth URLs when tools need authorization.
+ let elicitationCounter = 0;
+ setElicitationCallback((authUrl, message) => {
+ emit({
+ type: "elicitation",
+ elicitationId: `elicit-${++elicitationCounter}`,
+ authUrl,
+ message,
+ });
+ });
+ await mcpClient.elicitation.onRequest("arcade", async (request) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const params = request as any;
+ // Arcade sends the auth URL in message or a url field
+ const authUrl: string = params?.url ?? params?.message ?? "";
+ const message: string =
+ typeof params?.message === "string" ? params.message : "Authorization required";
+ if (authUrl && _elicitationCallback) {
+ _elicitationCallback(authUrl, message);
+ }
+ return { action: "accept" };
+ });
- // 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: planPrompt,
+ });
+
+ const result = await agent.stream(prompt, {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ toolsets: filteredToolsets as any,
+ maxSteps: 20,
+ onStepFinish: (step) => {
+ // 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 toolName = call.payload?.toolName;
+ if (!toolName) continue;
+ const source = mapToolToSource(toolName);
+ emit({
+ type: "status",
+ message: `Calling ${source}: ${toolName}...`,
+ });
+ }
+
+ const toolNameByCallId = new Map(
+ 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.payload?.output);
+ if (authUrl) {
+ const matchedToolName = tr.payload?.toolCallId
+ ? toolNameByCallId.get(tr.payload.toolCallId)
+ : undefined;
+ const fallbackToolName = i < toolCalls.length
+ ? toolCalls[i].payload?.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("invalid_authorization") ||
+ msg.includes("Missing Authorization") ||
+ msg.includes("Unauthorized");
+ if (isAuthError) {
+ // Clear stored tokens so the connect route detects re-auth is needed
+ // on the next check (page reload or window focus).
+ clearArcadeTokens();
+ }
+ 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] = {
@@ -312,3 +560,4 @@ export async function POST() {
},
});
}
+{{/if}}
diff --git a/templates/ai-sdk/app/api/auth/arcade/connect/route.ts b/templates/ai-sdk/app/api/auth/arcade/connect/route.ts
index 3c0f724..c68c2c7 100644
--- a/templates/ai-sdk/app/api/auth/arcade/connect/route.ts
+++ b/templates/ai-sdk/app/api/auth/arcade/connect/route.ts
@@ -1,6 +1,5 @@
import {
oauthProvider,
- getArcadeMCPClient,
getPendingAuthUrl,
clearPendingAuthUrl,
initiateOAuth,
@@ -30,23 +29,15 @@ export async function POST() {
);
}
- // Fast path: tokens already on disk.
+ // Fast path: tokens already on disk — trust them without a live verification
+ // request. Making a live request here caused a race condition where freshly-
+ // exchanged tokens could get a transient 401 from the gateway before it fully
+ // processed them, forcing an unnecessary second sign-in. If a stored token is
+ // actually expired the plan route surfaces an auth error and the user can
+ // reconnect from the error state on the dashboard.
const existingTokens = oauthProvider.tokens();
if (existingTokens?.access_token) {
- const connStatus = await verifyExistingConnection();
- if (connStatus === "ok") return Response.json({ connected: true });
- if (connStatus === "unreachable") {
- // Don't trigger a new OAuth redirect for transient connectivity failures —
- // that would force the user to re-authorize when the gateway is just briefly down.
- return Response.json(
- {
- connected: false,
- error: "Cannot reach Arcade Gateway. Check ARCADE_GATEWAY_URL and try again.",
- },
- { status: 502 }
- );
- }
- // "needs_reauth": tokens invalid/expired — fall through to OAuth
+ return Response.json({ connected: true });
}
if (!connectPromise) {
@@ -63,7 +54,6 @@ async function doConnect(): Promise<{
data: Record;
status?: number;
}> {
- let mcpClient: Awaited> | null = null;
try {
// Trigger MCP OAuth flow (discovery, registration, PKCE)
const result = await initiateOAuth();
@@ -76,12 +66,8 @@ async function doConnect(): Promise<{
}
}
- // AUTHORIZED — verify by listing tools
- mcpClient = await getArcadeMCPClient();
- const tools = await mcpClient.tools();
- return {
- data: { connected: true, toolCount: Object.keys(tools).length },
- };
+ // AUTHORIZED
+ return { data: { connected: true } };
} catch {
const authUrl = getPendingAuthUrl();
if (authUrl) {
@@ -97,34 +83,5 @@ async function doConnect(): Promise<{
},
status: 502,
};
- } finally {
- if (mcpClient) {
- try {
- await mcpClient.close();
- } catch {
- // Ignore close errors from failed initialization attempts.
- }
- }
- }
-}
-
-async function verifyExistingConnection(): Promise<"ok" | "needs_reauth" | "unreachable"> {
- let mcpClient: Awaited> | null = null;
- try {
- mcpClient = await getArcadeMCPClient();
- await mcpClient.tools();
- return "ok";
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- if (/401|403|unauthorized|forbidden/i.test(msg)) return "needs_reauth";
- return "unreachable";
- } finally {
- if (mcpClient) {
- try {
- await mcpClient.close();
- } catch {
- // Best effort cleanup.
- }
- }
}
}
diff --git a/templates/ai-sdk/app/api/auth/arcade/disconnect/route.ts b/templates/ai-sdk/app/api/auth/arcade/disconnect/route.ts
new file mode 100644
index 0000000..ab445d5
--- /dev/null
+++ b/templates/ai-sdk/app/api/auth/arcade/disconnect/route.ts
@@ -0,0 +1,11 @@
+import { clearArcadeTokens } from "@/lib/arcade";
+import { getSession } from "@/lib/auth";
+
+export async function POST() {
+ const session = await getSession();
+ if (!session) {
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ clearArcadeTokens();
+ return Response.json({ success: true });
+}
diff --git a/templates/ai-sdk/package.json b/templates/ai-sdk/package.json
index 675de8d..35a3bf9 100644
--- a/templates/ai-sdk/package.json
+++ b/templates/ai-sdk/package.json
@@ -15,10 +15,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/auth.py b/templates/langchain/app/routes/auth.py
index baad4a7..d71927e 100644
--- a/templates/langchain/app/routes/auth.py
+++ b/templates/langchain/app/routes/auth.py
@@ -4,7 +4,9 @@
all password hashing and user management to FastAPI Users' UserManager.
"""
+import contextlib
from dataclasses import dataclass
+from pathlib import Path
from fastapi import APIRouter, Depends, Response
from fastapi.responses import JSONResponse
@@ -15,6 +17,8 @@
from app.auth_manager import UserManager, get_jwt_strategy, get_user_manager
from app.config import settings
+_ARCADE_TOKENS_FILE = Path(__file__).resolve().parents[2] / ".arcade-auth" / "tokens.json"
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -84,5 +88,8 @@ async def login(
@router.post("/logout")
async def logout(response: Response):
+ # Clear Arcade tokens so the next user who signs in has to re-authenticate.
+ with contextlib.suppress(OSError):
+ _ARCADE_TOKENS_FILE.unlink()
response.delete_cookie("session_id", path="/")
return {"success": True}
diff --git a/templates/langchain/app/routes/plan.py b/templates/langchain/app/routes/plan.py
index ef92375..1cb8dd9 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,7 +239,15 @@ 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
+
+ MAX_TOOL_RESULT_CHARS = 2000
+
+ def _truncate(result) -> str:
+ """Serialize result to string and truncate if over the limit."""
+ s = result if isinstance(result, str) else json.dumps(result)
+ if len(s) > MAX_TOOL_RESULT_CHARS:
+ return s[:MAX_TOOL_RESULT_CHARS] + f"\n...[truncated {len(s) - MAX_TOOL_RESULT_CHARS} chars]"
+ return s
def _wrap_tool(tool):
"""Wrap a tool to truncate large results and prevent context overflow."""
@@ -237,26 +256,15 @@ def _wrap_tool(tool):
async def _truncated_ainvoke(*args, **kwargs):
result = await original_coroutine(*args, **kwargs)
- if isinstance(result, str) and len(result) > MAX_TOOL_RESULT_CHARS:
- return (
- result[:MAX_TOOL_RESULT_CHARS]
- + f"\n...[truncated {len(result) - MAX_TOOL_RESULT_CHARS} chars]"
- )
- return result
+ return _truncate(result)
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 ""
- )
- if len(s) > MAX_TOOL_RESULT_CHARS:
- return (
- s[:MAX_TOOL_RESULT_CHARS]
- + f"\n...[truncated {len(s) - MAX_TOOL_RESULT_CHARS} chars]"
- )
- return result
+ return _truncate(result)
+
updates["func"] = _truncated_invoke
return tool.model_copy(update=updates)
@@ -321,10 +329,17 @@ def _truncated_invoke(*args, **kwargs):
"work, upcoming events, and unread messages."
)
+ # recursion_limit counts graph-node transitions; react_agent has
+ # 2 nodes (agent + tools), so limit 40 ≈ 20 LLM iterations.
async for event in agent.astream(
{"messages": [HumanMessage(content=user_message)]},
stream_mode="updates",
+ config={"recursion_limit": 40},
):
+ # 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 +428,8 @@ def _truncated_invoke(*args, **kwargs):
}
)
yield _encode_event({"type": "done"})
+ finally:
+ set_elicitation_callback(None)
return StreamingResponse(
stream(),
diff --git a/templates/langchain/app/static/dashboard.js b/templates/langchain/app/static/dashboard.js
index 6be914e..ef2ee2e 100644
--- a/templates/langchain/app/static/dashboard.js
+++ b/templates/langchain/app/static/dashboard.js
@@ -8,8 +8,6 @@ const gateAuth = document.getElementById("gate-auth");
const gateAuthLink = document.getElementById("gate-auth-link");
const gateAuthCta = document.getElementById("gate-auth-cta");
const gateAuthWaiting = document.getElementById("gate-auth-waiting");
-const gateAuthRetry = document.getElementById("gate-auth-retry");
-const gateAuthWaitingRetry = document.getElementById("gate-auth-waiting-retry");
const gateError = document.getElementById("gate-error");
const gateErrorMsg = document.getElementById("gate-error-msg");
const dashboardArea = document.getElementById("dashboard-area");
@@ -120,8 +118,6 @@ function retryArcadeConnection() {
}
gateAuthLink.addEventListener("click", showGateAuthWaiting);
-gateAuthRetry.addEventListener("click", retryArcadeConnection);
-gateAuthWaitingRetry.addEventListener("click", retryArcadeConnection);
checkArcadeConnection();
window.addEventListener("focus", () => {
if (!dashboardArea.classList.contains("hidden")) return;
@@ -129,6 +125,13 @@ window.addEventListener("focus", () => {
retryArcadeConnection();
});
+// Re-check source statuses quickly after the user returns from an auth tab.
+// visibilitychange fires as soon as the tab becomes visible (faster than focus).
+document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState !== "visible") return;
+ if (authPrompts.children.length) checkSourceStatuses();
+});
+
// --- Logout ---
logoutBtn.addEventListener("click", async () => {
await fetch("/api/auth/logout", { method: "POST" });
@@ -314,9 +317,23 @@ function showState() {
}
// --- Auth prompts ---
+let _sourceAuthPollId = null;
+function startSourceAuthPolling() {
+ if (_sourceAuthPollId) return;
+ _sourceAuthPollId = setInterval(() => {
+ if (!authPrompts.children.length) {
+ clearInterval(_sourceAuthPollId);
+ _sourceAuthPollId = null;
+ return;
+ }
+ checkSourceStatuses();
+ }, 3000);
+}
+
function addAuthPrompt(url, toolName) {
if (authPrompts.querySelector(`[data-url="${CSS.escape(url)}"]`)) return;
authPrompts.classList.remove("hidden");
+ startSourceAuthPolling();
const label = toolName || "Service";
const card = document.createElement("div");
diff --git a/templates/langchain/app/templates/dashboard.html b/templates/langchain/app/templates/dashboard.html
index 0d9ba29..e0676e3 100644
--- a/templates/langchain/app/templates/dashboard.html
+++ b/templates/langchain/app/templates/dashboard.html
@@ -109,56 +109,32 @@
- 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.
-
+
Waiting for authorization...
-
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 +233,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 +278,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.