Skip to Content

Mastra  is an open-source, TypeScript framework for building AI applications. It provides agents with memory, calling, workflows, and RAG capabilities. This guide uses Mastra v1.x.

In this guide, you’ll build an agent lets you read emails, send messages, and interact with Gmail and Slack using Arcade’s in a conversational interface with built-in authentication. You will also build a workflow that summarizes emails and sends them to Slack.

Outcomes

A Mastra and workflow that integrates Arcade for Gmail and Slack.

You will Learn

  • How to retrieve Arcade and convert them to Mastra format
  • How to create an with calling capabilities
  • How to create a workflow with multiple steps
  • How to handle Arcade’s authorization flow in your application
  • How to test your and workflow with Mastra Studio

Prerequisites

Mastra concepts

Before diving into the code, here are the key Mastra concepts you’ll use:

  • Mastra Studio : An interactive development environment for building and testing locally.
  • Zod schemas : Mastra uses Zod for type-safe definitions.
  • Memory : Persists conversation history across sessions using storage backends like LibSQL.
  • Processors : Transform messages before they reach the LLM. This tutorial uses:
    • ToolCallFilter: Removes tool calls and results from memory to prevent large API responses from bloating .
    • TokenLimiterProcessor: Limits input tokens to stay within model limits.

Build an agent

Create a new Mastra project

Terminal
npx create-mastra@latest arcade-agent

Select your preferred model provider when prompted (we recommend OpenAI). Enter your when asked.

Then navigate to the project directory and install the :

Terminal
cd arcade-agent npm install @arcadeai/arcadejs @ai-sdk/openai zod@3

We explicitly install zod@3 because the Arcade SDK’s toZodToolSet currently requires Zod 3.x. Zod 4 has a different internal API that isn’t yet supported.

Set up environment variables

Add your key to .env:

ENV
.env
ARCADE_API_KEY={arcade_api_key} ARCADE_USER_ID={arcade_user_id}

The ARCADE_USER_ID is your app’s internal identifier for the (often the email you signed up with, a UUID, etc.). Arcade uses this to track authorizations per user.

Create the tool configuration

Create src/mastra//arcade.ts to handle Arcade tool fetching and conversion.

Handling large outputs: Tools like Gmail.ListEmails can return 200KB+ of email content. When this data is passed back to the LLM in the agentic loop, it can exceed token limits and cause rate limit errors. The code below includes output truncation to prevent this.

TypeScript
src/mastra/tools/arcade.ts
import { Arcade } from "@arcadeai/arcadejs"; import { toZodToolSet, executeOrAuthorizeZodTool, } from "@arcadeai/arcadejs/lib"; const config = { // Get all tools from these MCP servers mcpServers: ["Slack"], // Add specific individual tools individualTools: ["Gmail_ListEmails", "Gmail_SendEmail", "Gmail_WhoAmI"], }; // Maximum characters for any string field in tool output // Keeps responses small while preserving structure (subjects, senders, snippets) const MAX_STRING_CHARS = 300; /** * Recursively truncates all large strings in objects/arrays. * This prevents token overflow when tool results are passed back to the LLM. */ function truncateDeep(obj: unknown): unknown { if (obj === null || obj === undefined) return obj; if (typeof obj === "string") { if (obj.length > MAX_STRING_CHARS) { return obj.slice(0, MAX_STRING_CHARS) + "..."; } return obj; } if (Array.isArray(obj)) { return obj.map(truncateDeep); } if (typeof obj === "object") { const result: Record<string, unknown> = {}; for (const [key, value] of Object.entries(obj as Record<string, unknown>)) { result[key] = truncateDeep(value); } return result; } return obj; } export async function getArcadeTools(userId: string) { const arcade = new Arcade(); // Fetch tools from MCP servers const mcpTools = await Promise.all( config.mcpServers.map(async (server) => { const response = await arcade.tools.list({ toolkit: server }); return response.items; }) ); // Fetch individual tools const individualTools = await Promise.all( config.individualTools.map((toolName) => arcade.tools.get(toolName)) ); // Combine all tools const allTools = [...mcpTools.flat(), ...individualTools]; // Convert to Zod format for Mastra compatibility const zodTools = toZodToolSet({ tools: allTools, client: arcade, userId, executeFactory: executeOrAuthorizeZodTool, }); // Wrap tools with truncation and add 'id' property for Mastra Studio type ToolType = (typeof zodTools)[string] & { id: string }; const mastraTools: Record<string, ToolType> = {}; for (const [toolName, tool] of Object.entries(zodTools)) { const originalExecute = tool.execute; mastraTools[toolName] = { ...tool, id: toolName, execute: async (input: unknown) => { const result = await originalExecute(input); return truncateDeep(result) as Awaited<ReturnType<typeof originalExecute>>; }, } as ToolType; } return mastraTools; }

What this code does

Tool fetching
  • mcpServers: Fetches all tools from an server. Use this when you want everything a service offers (e.g., "Slack" gives you Slack_SendMessage, Slack_ListChannels, Slack_ListUsers, etc.)
  • individualTools: Fetches specific by name. Use this to cherry-pick only what you need (e.g., "Gmail_ListEmails" without Gmail_DeleteEmail or other tools you don’t want exposed)

There are a few reasons you might want to select your individually.

  • Security You may not want to expose all the a service offers, for instance Gmail_DeleteEmail is not necessary and could even be dangerous to expose to an designed to summarize emails.
  • Cost Each ’s schema consumes tokens. Loading all Gmail tools (~20 tools) uses significantly more tokens than loading just the 3 you need. This matters for rate limits and cost.

Browse the complete MCP server catalog to see available servers and their .

Arcade SDK functions
  • arcade.tools.list({ toolkit }): Fetches all tools from an server
  • arcade.tools.get(toolName): Fetches a single by its full name
  • toZodToolSet: Converts Arcade to Zod  schemas that Mastra requires
  • executeOrAuthorizeZodTool: Handles execution and returns authorization URLs when needed

Output handling

  • truncateDeep: Recursively limits all strings to 300 characters to prevent token overflow when results are passed back to the LLM

Create the agent

Create src/mastra//arcade.ts:

TypeScript
src/mastra/agents/arcade.ts
import { Agent } from "@mastra/core/agent"; import { TokenLimiterProcessor, ToolCallFilter } from "@mastra/core/processors"; import { Memory } from "@mastra/memory"; import { LibSQLStore } from "@mastra/libsql"; import { openai } from "@ai-sdk/openai"; import { getArcadeTools } from "../tools/arcade"; const userId = process.env.ARCADE_USER_ID || "default-user"; // Fetch Arcade tools at startup const arcadeTools = await getArcadeTools(userId); // Configure memory with conversation history const memory = new Memory({ storage: new LibSQLStore({ id: "arcade-agent-memory", url: "file:memory.db", }), options: { lastMessages: 10, }, }); export const arcadeAgent = new Agent({ id: "arcade-agent", name: "arcadeAgent", instructions: `You are a helpful assistant that can access Gmail and Slack. Always use the available tools to fulfill user requests. For Gmail: - Use Gmail_ListEmails to fetch recent emails - Use Gmail_SendEmail to send emails - Use Gmail_WhoAmI to get the user's email address - To find sent emails, use the query parameter with "in:sent" - To find received emails, use "in:inbox" or no query - When composing emails, use plain text (no markdown) For Slack: - Use Slack_SendMessage to send messages to channels or users - Use Slack_ListChannels to see available channels After completing any action, always confirm what you did with specific details. IMPORTANT: When a tool returns an authorization response with a URL, tell the user to visit that URL to grant access. After they authorize, they can retry their request.`, model: openai("gpt-4o"), tools: arcadeTools, memory, // Filter out tool results from memory (they can be huge) and limit tokens inputProcessors: [new ToolCallFilter(), new TokenLimiterProcessor({ limit: 50000 })], });

Register the agent

Replace the contents of src/mastra/index.ts with the following to register your :

TypeScript
src/mastra/index.ts
import { Mastra } from "@mastra/core"; import { arcadeAgent } from "./agents/arcade"; export const mastra = new Mastra({ agents: { arcadeAgent, }, });

Test with Mastra Studio

Start the development server:

Terminal
npm run dev

Open http://localhost:4111  to access Mastra Studio. Select arcadeAgent from the list and try prompts like:

  • “Summarize my last 3 emails”
  • “Send a Slack DM to myself saying hello”
  • “What’s my Gmail address?”

On first use, the agent will return an authorization URL. Visit the URL to connect your Gmail or Slack , then retry your request. Arcade remembers this authorization for future requests.

Mastra Studio agent interaction

Build a workflow

are great for open-ended conversations, but sometimes you want a deterministic process that runs the same way every time. Mastra workflows let you chain steps together, with each step’s output feeding into the next.

This workflow does the following:

  1. Fetches emails from Gmail
  2. Summarizes them with an LLM
  3. Sends the digest as a direct message to the on Slack

This also demonstrates how workflows:

  • handle large data the full email content stays internal to the workflow, and only the compact summary gets sent to Slack.
  • handle authorization errors
  • pass auth URLs through multiple workflow steps

Create the workflow

Create src/mastra/workflows/email-digest.ts:

TypeScript
src/mastra/workflows/email-digest.ts
import { createStep, createWorkflow } from "@mastra/core/workflows"; import { z } from "zod"; import { Arcade } from "@arcadeai/arcadejs"; const defaultUserId = process.env.ARCADE_USER_ID || ""; // Step 1: Fetch emails from Gmail const fetchEmails = createStep({ id: "fetch-emails", inputSchema: z.object({ userId: z.string().optional(), maxEmails: z.number().optional(), }), outputSchema: z.object({ emails: z.array(z.object({ subject: z.string(), from: z.string(), snippet: z.string(), })), userId: z.string(), authRequired: z.boolean().optional(), authUrl: z.string().optional(), }), execute: async ({ inputData }) => { const arcade = new Arcade(); const userId = inputData?.userId || defaultUserId; try { const result = await arcade.tools.execute({ tool_name: "Gmail.ListEmails", user_id: userId, input: { n_emails: inputData!.maxEmails ?? 5 }, }); const response = result as { output?: { value?: { emails?: any[] } } }; const emails = (response.output?.value?.emails || []).map((e: any) => ({ subject: String(e.subject || "(No subject)"), from: String(e.sender || e.from || "Unknown"), snippet: String(e.snippet || "").slice(0, 200), })); return { emails, userId }; } catch (error: any) { // Handle authorization required error if (error.status === 403 || error.message?.includes("authorization")) { const authResponse = await arcade.auth.start({ user_id: userId, provider: "google", scopes: ["https://www.googleapis.com/auth/gmail.readonly"], }); return { emails: [], userId, authRequired: true, authUrl: authResponse.url, }; } throw error; } }, }); // Step 2: Summarize with LLM const summarizeEmails = createStep({ id: "summarize-emails", inputSchema: z.object({ emails: z.array(z.object({ subject: z.string(), from: z.string(), snippet: z.string(), })), userId: z.string(), authRequired: z.boolean().optional(), authUrl: z.string().optional(), }), outputSchema: z.object({ summary: z.string(), userId: z.string(), authRequired: z.boolean().optional(), authUrl: z.string().optional(), }), execute: async ({ inputData, mastra }) => { const { emails, userId, authRequired, authUrl } = inputData!; // Pass through auth requirement if (authRequired) { return { summary: "", userId, authRequired, authUrl }; } if (emails.length === 0) { return { summary: "No new emails.", userId }; } const agent = mastra?.getAgent("arcadeAgent"); const emailList = emails.map((e, i) => `${i + 1}. From: ${e.from}\n Subject: ${e.subject}\n Preview: ${e.snippet}` ).join("\n\n"); const response = await agent!.generate( `Summarize these emails in 2-3 bullet points:\n\n${emailList}` ); return { summary: response.text, userId }; }, }); // Step 3: Send DM to Slack user const sendToSlack = createStep({ id: "send-to-slack", inputSchema: z.object({ summary: z.string(), userId: z.string(), authRequired: z.boolean().optional(), authUrl: z.string().optional(), }), outputSchema: z.object({ success: z.boolean(), message: z.string(), authUrl: z.string().optional(), }), execute: async ({ inputData }) => { const { summary, userId, authRequired, authUrl } = inputData!; // Return auth URL if authorization is needed if (authRequired) { return { success: false, message: `Authorization required. Please visit this URL to grant access: ${authUrl}`, authUrl, }; } const arcade = new Arcade(); try { // Get the user's Slack identity const whoAmI = await arcade.tools.execute({ tool_name: "Slack_WhoAmI", user_id: userId, input: {}, }); const slackUserId = (whoAmI as any)?.output?.value?.user_id; // Send DM to the user await arcade.tools.execute({ tool_name: "Slack_SendMessage", user_id: userId, input: { message: `📬 *Email Digest*\n\n${summary}`, user_ids: [slackUserId], }, }); return { success: true, message: "Digest sent as DM" }; } catch (error: any) { // Handle Slack authorization required if (error.status === 403 || error.message?.includes("authorization")) { const slackAuth = await arcade.auth.start({ user_id: userId, provider: "slack", scopes: ["chat:write", "users:read"], }); return { success: false, message: `Slack authorization required. Please visit: ${slackAuth.url}`, authUrl: slackAuth.url, }; } throw error; } }, }); // Chain the steps together const emailDigestWorkflow = createWorkflow({ id: "email-digest", inputSchema: z.object({ userId: z.string().default(defaultUserId), maxEmails: z.number().default(5), }), outputSchema: z.object({ success: z.boolean(), message: z.string(), authUrl: z.string().optional(), }), }) .then(fetchEmails) .then(summarizeEmails) .then(sendToSlack); emailDigestWorkflow.commit(); export { emailDigestWorkflow };

Register the workflow

Update src/mastra/index.ts:

TypeScript
src/mastra/index.ts
import { Mastra } from "@mastra/core"; import { arcadeAgent } from "./agents/arcade"; import { emailDigestWorkflow } from "./workflows/email-digest"; export const mastra = new Mastra({ agents: { arcadeAgent, }, workflows: { emailDigestWorkflow, }, });

Test the workflow

  1. Restart the dev server and open Mastra Studio. In the sidebar, open Workflows. Select email-digest.
  2. In the right sidebar, select “run” to run the workflow.
  3. If authorization is required, the workflow returns an auth URL. Visit the URL, complete authorization, then run the workflow again.
  4. Check your Slack DMs for the digest.

Mastra Studio workflow run

Key takeaways

  • Arcade work seamlessly with Mastra: Use toZodToolSet to convert Arcade tools to the Zod schema format Mastra expects.
  • vs Workflow: The agent handles open-ended requests (“help me with my emails”). The workflow handles repeatable processes (“every morning, summarize and send to Slack”). Use both together for powerful automation.
  • Truncate large outputs: Tools like Gmail can return 200KB+ of data. Wrap execution with truncation to prevent token overflow in the agentic loop.
  • Authorization is automatic: The executeOrAuthorizeZodTool factory handles auth flows. When a needs authorization, it returns a URL for the to visit.
  • Workflows need explicit auth handling: Unlike , workflows don’t have built-in auth handling. Catch 403 errors, call arcade.auth.start(), and pass the auth URL through your workflow steps.
  • Use for conversation, workflows for automation: Agents handle open-ended requests; workflows handle repeatable, deterministic processes.

Next steps

  • Add more : Browse the tool catalog and add tools for GitHub, Notion, Linear, and more.
  • Schedule your workflow: Use a cron job or Mastra’s scheduling  to run your email digest every morning.
  • Deploy to production: Follow Mastra’s deployment guides  to deploy your and workflows.

Building a multi- app? This tutorial uses a single ARCADE_USER_ID for simplicity. For production apps where each user needs their own OAuth tokens, see Secure auth for production to learn how to dynamically pass user IDs and handle per-user authorization.

Complete code

src/mastra/tools/arcade.ts (full file)

TypeScript
src/mastra/tools/arcade.ts
import { Arcade } from "@arcadeai/arcadejs"; import { toZodToolSet, executeOrAuthorizeZodTool, } from "@arcadeai/arcadejs/lib"; const config = { mcpServers: ["Slack"], individualTools: ["Gmail_ListEmails", "Gmail_SendEmail", "Gmail_WhoAmI"], }; const MAX_STRING_CHARS = 300; function truncateDeep(obj: unknown): unknown { if (obj === null || obj === undefined) return obj; if (typeof obj === "string") { if (obj.length > MAX_STRING_CHARS) { return obj.slice(0, MAX_STRING_CHARS) + "..."; } return obj; } if (Array.isArray(obj)) { return obj.map(truncateDeep); } if (typeof obj === "object") { const result: Record<string, unknown> = {}; for (const [key, value] of Object.entries(obj as Record<string, unknown>)) { result[key] = truncateDeep(value); } return result; } return obj; } export async function getArcadeTools(userId: string) { const arcade = new Arcade(); const mcpTools = await Promise.all( config.mcpServers.map(async (server) => { const response = await arcade.tools.list({ toolkit: server }); return response.items; }) ); const individualTools = await Promise.all( config.individualTools.map((toolName) => arcade.tools.get(toolName)) ); const allTools = [...mcpTools.flat(), ...individualTools]; const zodTools = toZodToolSet({ tools: allTools, client: arcade, userId, executeFactory: executeOrAuthorizeZodTool, }); type ToolType = (typeof zodTools)[string] & { id: string }; const mastraTools: Record<string, ToolType> = {}; for (const [toolName, tool] of Object.entries(zodTools)) { const originalExecute = tool.execute; mastraTools[toolName] = { ...tool, id: toolName, execute: async (input: unknown) => { const result = await originalExecute(input); return truncateDeep(result) as Awaited<ReturnType<typeof originalExecute>>; }, } as ToolType; } return mastraTools; }

src/mastra/agents/arcade.ts (full file)

TypeScript
src/mastra/agents/arcade.ts
import { Agent } from "@mastra/core/agent"; import { TokenLimiterProcessor, ToolCallFilter } from "@mastra/core/processors"; import { Memory } from "@mastra/memory"; import { LibSQLStore } from "@mastra/libsql"; import { openai } from "@ai-sdk/openai"; import { getArcadeTools } from "../tools/arcade"; const userId = process.env.ARCADE_USER_ID || "default-user"; const arcadeTools = await getArcadeTools(userId); const memory = new Memory({ storage: new LibSQLStore({ id: "arcade-agent-memory", url: "file:memory.db", }), options: { lastMessages: 10, }, }); export const arcadeAgent = new Agent({ id: "arcade-agent", name: "arcadeAgent", instructions: `You are a helpful assistant that can access Gmail and Slack. Always use the available tools to fulfill user requests. For Gmail: - Use Gmail_ListEmails to fetch recent emails - Use Gmail_SendEmail to send emails - Use Gmail_WhoAmI to get the user's email address - To find sent emails, use the query parameter with "in:sent" - To find received emails, use "in:inbox" or no query - When composing emails, use plain text (no markdown) For Slack: - Use Slack_SendMessage to send messages to channels or users - Use Slack_ListChannels to see available channels After completing any action, always confirm what you did with specific details. IMPORTANT: When a tool returns an authorization response with a URL, tell the user to visit that URL to grant access. After they authorize, they can retry their request.`, model: openai("gpt-4o"), tools: arcadeTools, memory, // Filter out tool results from memory (they can be huge) and limit tokens inputProcessors: [new ToolCallFilter(), new TokenLimiterProcessor({ limit: 50000 })], });

src/mastra/index.ts (full file)

TypeScript
src/mastra/index.ts
import { Mastra } from "@mastra/core"; import { arcadeAgent } from "./agents/arcade"; import { emailDigestWorkflow } from "./workflows/email-digest"; export const mastra = new Mastra({ agents: { arcadeAgent, }, workflows: { emailDigestWorkflow, }, });

src/mastra/workflows/email-digest.ts (full file)

TypeScript
src/mastra/workflows/email-digest.ts
import { createStep, createWorkflow } from "@mastra/core/workflows"; import { z } from "zod"; import { Arcade } from "@arcadeai/arcadejs"; const defaultUserId = process.env.ARCADE_USER_ID || ""; const fetchEmails = createStep({ id: "fetch-emails", inputSchema: z.object({ userId: z.string().optional(), maxEmails: z.number().optional(), }), outputSchema: z.object({ emails: z.array(z.object({ subject: z.string(), from: z.string(), snippet: z.string(), })), userId: z.string(), authRequired: z.boolean().optional(), authUrl: z.string().optional(), }), execute: async ({ inputData }) => { const arcade = new Arcade(); const userId = inputData?.userId || defaultUserId; try { const result = await arcade.tools.execute({ tool_name: "Gmail.ListEmails", user_id: userId, input: { n_emails: inputData!.maxEmails ?? 5 }, }); const response = result as { output?: { value?: { emails?: any[] } } }; const emails = (response.output?.value?.emails || []).map((e: any) => ({ subject: String(e.subject || "(No subject)"), from: String(e.sender || e.from || "Unknown"), snippet: String(e.snippet || "").slice(0, 200), })); return { emails, userId }; } catch (error: any) { if (error.status === 403 || error.message?.includes("authorization")) { const authResponse = await arcade.auth.start({ user_id: userId, provider: "google", scopes: ["https://www.googleapis.com/auth/gmail.readonly"], }); return { emails: [], userId, authRequired: true, authUrl: authResponse.url, }; } throw error; } }, }); const summarizeEmails = createStep({ id: "summarize-emails", inputSchema: z.object({ emails: z.array(z.object({ subject: z.string(), from: z.string(), snippet: z.string(), })), userId: z.string(), authRequired: z.boolean().optional(), authUrl: z.string().optional(), }), outputSchema: z.object({ summary: z.string(), userId: z.string(), authRequired: z.boolean().optional(), authUrl: z.string().optional(), }), execute: async ({ inputData, mastra }) => { const { emails, userId, authRequired, authUrl } = inputData!; if (authRequired) { return { summary: "", userId, authRequired, authUrl }; } if (emails.length === 0) { return { summary: "No new emails.", userId }; } const agent = mastra?.getAgent("arcadeAgent"); const emailList = emails.map((e, i) => `${i + 1}. From: ${e.from}\n Subject: ${e.subject}\n Preview: ${e.snippet}` ).join("\n\n"); const response = await agent!.generate( `Summarize these emails in 2-3 bullet points:\n\n${emailList}` ); return { summary: response.text, userId }; }, }); const sendToSlack = createStep({ id: "send-to-slack", inputSchema: z.object({ summary: z.string(), userId: z.string(), authRequired: z.boolean().optional(), authUrl: z.string().optional(), }), outputSchema: z.object({ success: z.boolean(), message: z.string(), authUrl: z.string().optional(), }), execute: async ({ inputData }) => { const { summary, userId, authRequired, authUrl } = inputData!; if (authRequired) { return { success: false, message: `Authorization required. Please visit this URL to grant access: ${authUrl}`, authUrl, }; } const arcade = new Arcade(); try { const whoAmI = await arcade.tools.execute({ tool_name: "Slack_WhoAmI", user_id: userId, input: {}, }); const slackUserId = (whoAmI as any)?.output?.value?.user_id; await arcade.tools.execute({ tool_name: "Slack_SendMessage", user_id: userId, input: { message: `📬 *Email Digest*\n\n${summary}`, user_ids: [slackUserId], }, }); return { success: true, message: "Digest sent as DM" }; } catch (error: any) { if (error.status === 403 || error.message?.includes("authorization")) { const slackAuth = await arcade.auth.start({ user_id: userId, provider: "slack", scopes: ["chat:write", "users:read"], }); return { success: false, message: `Slack authorization required. Please visit: ${slackAuth.url}`, authUrl: slackAuth.url, }; } throw error; } }, }); const emailDigestWorkflow = createWorkflow({ id: "email-digest", inputSchema: z.object({ userId: z.string().default(defaultUserId), maxEmails: z.number().default(5), }), outputSchema: z.object({ success: z.boolean(), message: z.string(), authUrl: z.string().optional(), }), }) .then(fetchEmails) .then(summarizeEmails) .then(sendToSlack); emailDigestWorkflow.commit(); export { emailDigestWorkflow };
Last updated on