Skip to Content

Build an AI agent with Arcade and Spring AI

Spring AI  is a framework that brings AI capabilities to Spring Boot applications. It provides a unified API for interacting with large language models, including support for calling. This guide uses Spring AI 1.1.x with Spring Boot 3.5.

In this guide, you’ll build a Spring Boot web application that uses Arcade’s Gmail and Slack through Spring AI’s ChatClient. Your application exposes a REST endpoint that accepts natural language prompts, lets the LLM decide which tools to call, and handles Arcade’s authorization flow when users need to connect their .

Outcomes

Build a Spring Boot web application that integrates Arcade with Spring AI for Gmail and Slack access

You will Learn

  • How to configure the Arcade Spring Boot starter
  • How to expose Arcade to Spring AI using @Tool annotations
  • How to build a chat endpoint with calling
  • How to handle Arcade’s authorization flow

Spring AI concepts

Before diving into the code, here are the key Spring AI concepts used in this guide:

  • ChatClient  — A fluent API for interacting with AI models. It handles sending prompts, receiving responses, and coordinating calls.
  • @Tool annotation  — Marks a method as a that the AI model can call. Spring AI automatically generates the tool schema from the method signature and annotations.
  • OpenAI integration  — The spring-ai-starter-model-openai starter configures the OpenAI chat model with properties from application.properties.

Build the agent

Create a new Spring Boot project

Go to start.spring.io  and configure the with the following settings:

SettingValue
ProjectMaven (or Gradle)
LanguageJava
Spring Boot3.5.x
Java17
DependenciesSpring Web, OpenAI

Click Generate, extract the archive, and open the in your IDE.

The Spring Initializr may default to Spring Boot 4.x. Make sure to select a 3.5.x version from the dropdown.

Add the Arcade dependency

Add the arcade-spring-boot-starter to your . This starter auto-configures an ArcadeClient bean that your application can inject.

Add the dependency to your pom.xml:

XML
pom.xml
<dependency> <groupId>dev.arcade</groupId> <artifactId>arcade-spring-boot-starter</artifactId> <version>${arcade-java-version}</version> </dependency>

See the Arcade Java SDK docs  for the latest version.

Configure application properties

Replace the contents of src/main/resources/application.properties with the following:

PROPERTIES
application.properties
spring.application.name=arcade-agent # OpenAI spring.ai.openai.api-key=${OPENAI_API_KEY} spring.ai.openai.chat.options.model=gpt-4.1-mini # Arcade arcade.api-key=${ARCADE_API_KEY} arcade.user-id=${ARCADE_USER_ID}

Set these environment variables before running the application:

Terminal
export OPENAI_API_KEY=your-openai-api-key export ARCADE_API_KEY=your-arcade-api-key export ARCADE_USER_ID=[email protected]

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

Do not commit to version control. Use environment variables or a secrets manager.

Create the Arcade tool provider

Create src/main/java/com/example/arcadeagent/ArcadeToolProvider.java. This service wraps Arcade calls and exposes them to Spring AI using @Tool annotations:

Java
src/main/java/com/example/arcadeagent/ArcadeToolProvider.java
package com.example.arcadeagent; import dev.arcade.client.ArcadeClient; import dev.arcade.models.AuthorizationResponse; import dev.arcade.models.tools.AuthorizeToolRequest; import dev.arcade.models.tools.ExecuteToolRequest; import dev.arcade.models.tools.ExecuteToolResponse; import java.util.Map; import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; // Mark as a Spring-managed bean so it can be injected // into the ChatController @Service public class ArcadeToolProvider { private static final Logger log = LoggerFactory.getLogger(ArcadeToolProvider.class); // ArcadeClient is auto-configured by arcade-spring-boot-starter private final ArcadeClient client; // Identifies the current user for Arcade authorization tracking private final String userId; ArcadeToolProvider(ArcadeClient client, @Value("${arcade.user-id}") String userId) { this.client = client; this.userId = userId; } // --- Tool methods ------------------------------------------------ // Each @Tool method is exposed to the LLM by Spring AI. // The LLM reads the name and description to decide // when to call it. @Tool(name = "list_emails", description = "List recent emails from the user's Gmail inbox") public String listEmails( @ToolParam(description = "Search query, e.g. 'in:inbox'") String query, @ToolParam(description = "Maximum number of emails to return (default 5)") int limit) { return executeTool( "Gmail.ListEmails", Map.of( "query", query, "n_results", limit > 0 ? limit : 5) ); } @Tool(name = "send_email", description = "Send an email using Gmail") public String sendEmail( @ToolParam(description = "Recipient email address") String recipient, @ToolParam(description = "Email subject line") String subject, @ToolParam(description = "Email body text") String body) { return executeTool("Gmail.SendEmail", Map.of( "recipient", recipient, "subject", subject, "body", body )); } @Tool(name = "send_slack_message", description = "Send a message to a Slack channel") public String sendSlackMessage( @ToolParam(description = "The Slack channel name") String channelName, @ToolParam(description = "The message text to send") String message) { return executeTool( "Slack.SendMessage", Map.of("channel_name", channelName, "message", message) ); } // --- Arcade API helpers ------------------------------------------ /** * Calls an Arcade tool and returns the result as a String. * If the tool requires OAuth authorization, returns an * authorization URL instead. */ private String executeTool(String toolName, Map<String, Object> input) { // First, ensure the user has authorized this tool String authResult = handleAuthorization(toolName); if (authResult != null) { return authResult; } log.debug("Executing tool {}, input: {}", toolName, input); try { // Build and send the execute request to Arcade ExecuteToolResponse response = client.tools() .execute(ExecuteToolRequest.builder() .toolName(toolName) .userId(userId) .input(input) .build()); // Happy path: tool executed successfully if (response.success().orElse(false)) { String result = response.output() .map(o -> o._value().toString()) .orElse("{}"); log.debug("Tool {} returned: {}", toolName, result); return result; } // Check for an error in the response Optional<ExecuteToolResponse.Output.Error> error = response.output() .flatMap(ExecuteToolResponse.Output::error); if (error.isPresent()) { String msg = error.get().message(); log.warn("Tool {} error: {}", toolName, msg); return "Error: " + msg; } return "Error: tool execution failed"; } catch (Exception e) { log.error("Tool {} failed: {}", toolName, e.getMessage()); return "Error: " + e.getMessage(); } } /** * Checks whether the user has authorized the tool. * If authorization is already COMPLETED, returns null * (the caller can proceed). Otherwise, returns a message * with the authorization URL for the user to visit. */ private String handleAuthorization(String toolName) { try { // Start the authorization process AuthorizationResponse authResponse = client.tools() .authorize(AuthorizeToolRequest.builder() .toolName(toolName) .userId(userId) .build()); // Tools that do not require authorization (or that // the user has already authorized) return COMPLETED. Optional<AuthorizationResponse.Status> status = authResponse.status(); if (status.isPresent() && AuthorizationResponse .Status.COMPLETED.equals(status.get())) { return null; // authorized -- proceed with execution } // Not yet authorized -- return the URL for the user Optional<String> url = authResponse.url(); if (url.isPresent()) { log.debug("Tool {} requires authorization: {}", toolName, url.get()); return String.format( "Authorization required for '%s'. " + "Open this URL to authorize: %s", toolName, url.get() ); } return "Authorization not completed for: " + toolName; } catch (Exception e) { log.error( "Authorization request failed for {}: {}", toolName, e.getMessage() ); return "Error requesting authorization: " + e.getMessage(); } } }

How this works:

  • The ArcadeClient bean is auto-configured by the arcade-spring-boot-starter when arcade.api-key is set.
  • Each @Tool method wraps an Arcade call. Spring AI reads these annotations and makes the tools available to the LLM.
  • The executeTool method first checks authorization, then calls the . If authorization status is COMPLETED, it proceeds with execution. Otherwise, it returns an authorization URL for the to visit.

Create the chat controller

Create src/main/java/com/example/arcadeagent/ChatController.java. This controller exposes a REST endpoint that accepts prompts and returns AI responses:

Java
src/main/java/com/example/arcadeagent/ChatController.java
package com.example.arcadeagent; import org.springframework.ai.chat.client.ChatClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class ChatController { // System prompt that tells the LLM what tools it has // and how to handle authorization URLs private static final String SYSTEM_PROMPT = """ You are a helpful assistant with access to Gmail and Slack tools. Use the available tools to fulfill user requests. When a tool returns an authorization URL, include it in your response so the user can authorize access. After completing any action, confirm what you did with specific details."""; private final ChatClient chatClient; ChatController( ChatClient.Builder chatClientBuilder, ArcadeToolProvider toolProvider ) { this.chatClient = chatClientBuilder // Set the system prompt for every conversation .defaultSystem(SYSTEM_PROMPT) // Register all @Tool methods from ArcadeToolProvider .defaultTools(toolProvider) .build(); } // POST /api/chat -- accepts a user message, returns // the LLM response (which may include tool results) @PostMapping("/api/chat") public ChatResponse chat(@RequestBody ChatRequest request) { String response = chatClient .prompt() .user(request.message()) // the user's message .call() // send to the LLM .content(); // extract the text reply return new ChatResponse(response); } // Request and response records for the REST endpoint public record ChatRequest(String message) {} public record ChatResponse(String response) {} }

How this works:

  • The ChatClient is created with the system prompt and the ArcadeToolProvider registered as the default .
  • When a sends a message to POST /api/chat, the LLM processes it and decides whether to call any .
  • If the LLM calls a (for example, list_emails), Spring AI invokes the corresponding @Tool method on ArcadeToolProvider, which delegates to the .
  • The response is returned as JSON.

Run the application

Make sure your environment variables are set, then start the application:

Terminal
./mvnw spring-boot:run

Test the endpoint with curl:

Terminal
curl -X POST http://localhost:8080/api/chat \ -H "Content-Type: application/json" \ -d '{"message": "List my recent emails"}'

On first use, the response will include an authorization URL because the user hasn’t connected their Gmail yet. Open the URL in a browser to authorize, then retry the request:

JSON
{ "response": "It looks like I need access to your Gmail account. Please open this URL to authorize: https://accounts.google.com/o/oauth2/..." }

After authorization, subsequent requests will return results:

Terminal
curl -X POST http://localhost:8080/api/chat \ -H "Content-Type: application/json" \ -d '{"message": "Send a Slack message to #random saying hello from my AI agent"}'

Key takeaways

  • The Arcade Spring Boot starter auto-configures the client: Add arcade-spring-boot-starter to your dependencies and set arcade.api-key in your configuration. The starter creates an ArcadeClient bean you can inject anywhere.
  • @Tool annotations bridge Arcade and Spring AI: Annotate methods on a @Service class and pass it to ChatClient.Builder.defaultTools(). Spring AI handles the schema generation and invocation lifecycle.
  • Authorization is handled in the response: When Arcade returns an “authorization required” error, the tool provider requests an OAuth URL and returns it as part of the response. The LLM includes this URL in its reply to the .

Next steps

  1. Add more : Browse the MCP server catalog and add @Tool methods for GitHub, Google Docs, Notion, and more.
  2. Add authentication: In production, resolve userId from your authentication system instead of a static configuration property. See Secure auth in production for best practices.
  3. Stream responses: Replace .call() with .stream() on the ChatClient to stream responses using Server-Sent Events .

Complete code

pom.xml (full file)

XML
pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.0</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>arcade-agent</artifactId> <version>0.0.1-SNAPSHOT</version> <name>arcade-agent</name> <description>Spring AI agent with Arcade tools</description> <properties> <java.version>17</java.version> <spring-ai.version>1.1.3</spring-ai.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>dev.arcade</groupId> <artifactId>arcade-spring-boot-starter</artifactId> <version>${arcade-java-version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

src/main/resources/application.properties (full file)

PROPERTIES
src/main/resources/application.properties
spring.application.name=arcade-agent # OpenAI spring.ai.openai.api-key=${OPENAI_API_KEY} spring.ai.openai.chat.options.model=gpt-4.1-mini # Arcade arcade.api-key=${ARCADE_API_KEY} arcade.user-id=${ARCADE_USER_ID}

src/main/java/com/example/arcadeagent/ArcadeToolProvider.java (full file)

Java
src/main/java/com/example/arcadeagent/ArcadeToolProvider.java
package com.example.arcadeagent; import dev.arcade.client.ArcadeClient; import dev.arcade.models.AuthorizationResponse; import dev.arcade.models.tools.AuthorizeToolRequest; import dev.arcade.models.tools.ExecuteToolRequest; import dev.arcade.models.tools.ExecuteToolResponse; import java.util.Map; import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service public class ArcadeToolProvider { private static final Logger log = LoggerFactory.getLogger(ArcadeToolProvider.class); private final ArcadeClient client; private final String userId; ArcadeToolProvider(ArcadeClient client, @Value("${arcade.user-id}") String userId) { this.client = client; this.userId = userId; } @Tool(name = "list_emails", description = "List recent emails from the user's Gmail inbox") public String listEmails( @ToolParam(description = "Search query, e.g. 'in:inbox'") String query, @ToolParam(description = "Maximum number of emails to return (default 5)") int limit) { return executeTool( "Gmail.ListEmails", Map.of( "query", query, "n_results", limit > 0 ? limit : 5) ); } @Tool(name = "send_email", description = "Send an email using Gmail") public String sendEmail( @ToolParam(description = "Recipient email address") String recipient, @ToolParam(description = "Email subject line") String subject, @ToolParam(description = "Email body text") String body) { return executeTool("Gmail.SendEmail", Map.of( "recipient", recipient, "subject", subject, "body", body )); } @Tool(name = "send_slack_message", description = "Send a message to a Slack channel") public String sendSlackMessage( @ToolParam(description = "The Slack channel name") String channelName, @ToolParam(description = "The message text to send") String message) { return executeTool( "Slack.SendMessage", Map.of("channel_name", channelName, "message", message) ); } private String executeTool(String toolName, Map<String, Object> input) { String authResult = handleAuthorization(toolName); if (authResult != null) { return authResult; } log.debug("Executing tool {}, input: {}", toolName, input); try { ExecuteToolResponse response = client.tools() .execute(ExecuteToolRequest.builder() .toolName(toolName) .userId(userId) .input(input) .build()); if (response.success().orElse(false)) { String result = response.output() .map(o -> o._value().toString()) .orElse("{}"); log.debug("Tool {} returned: {}", toolName, result); return result; } Optional<ExecuteToolResponse.Output.Error> error = response.output() .flatMap(ExecuteToolResponse.Output::error); if (error.isPresent()) { String msg = error.get().message(); log.warn("Tool {} error: {}", toolName, msg); return "Error: " + msg; } return "Error: tool execution failed"; } catch (Exception e) { log.error("Tool {} failed: {}", toolName, e.getMessage()); return "Error: " + e.getMessage(); } } private String handleAuthorization(String toolName) { try { AuthorizationResponse authResponse = client.tools() .authorize(AuthorizeToolRequest.builder() .toolName(toolName) .userId(userId) .build()); Optional<AuthorizationResponse.Status> status = authResponse.status(); if (status.isPresent() && AuthorizationResponse .Status.COMPLETED.equals(status.get())) { return null; } Optional<String> url = authResponse.url(); if (url.isPresent()) { log.debug("Tool {} requires authorization: {}", toolName, url.get()); return String.format( "Authorization required for '%s'. " + "Open this URL to authorize: %s", toolName, url.get() ); } return "Authorization not completed for: " + toolName; } catch (Exception e) { log.error( "Authorization request failed for {}: {}", toolName, e.getMessage() ); return "Error requesting authorization: " + e.getMessage(); } } }

src/main/java/com/example/arcadeagent/ChatController.java (full file)

Java
src/main/java/com/example/arcadeagent/ChatController.java
package com.example.arcadeagent; import org.springframework.ai.chat.client.ChatClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class ChatController { private static final String SYSTEM_PROMPT = """ You are a helpful assistant with access to Gmail and Slack tools. Use the available tools to fulfill user requests. When a tool returns an authorization URL, include it in your response so the user can authorize access. After completing any action, confirm what you did with specific details."""; private final ChatClient chatClient; ChatController( ChatClient.Builder chatClientBuilder, ArcadeToolProvider toolProvider ) { this.chatClient = chatClientBuilder .defaultSystem(SYSTEM_PROMPT) .defaultTools(toolProvider) .build(); } @PostMapping("/api/chat") public ChatResponse chat(@RequestBody ChatRequest request) { String response = chatClient .prompt() .user(request.message()) .call() .content(); return new ChatResponse(response); } public record ChatRequest(String message) {} public record ChatResponse(String response) {} }
Last updated on