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
@Toolannotations - How to build a chat endpoint with calling
- How to handle Arcade’s authorization flow
Prerequisites
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-openaistarter configures the OpenAI chat model with properties fromapplication.properties.
Build the agent
Create a new Spring Boot project
Go to start.spring.io and configure the with the following settings:
| Setting | Value |
|---|---|
| Project | Maven (or Gradle) |
| Language | Java |
| Spring Boot | 3.5.x |
| Java | 17 |
| Dependencies | Spring 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.
Maven
Add the dependency to your 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:
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:
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:
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
ArcadeClientbean is auto-configured by thearcade-spring-boot-starterwhenarcade.api-keyis set. - Each
@Toolmethod wraps an Arcade call. Spring AI reads these annotations and makes the tools available to the LLM. - The
executeToolmethod first checks authorization, then calls the . If authorization status isCOMPLETED, 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:
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
ChatClientis created with the system prompt and theArcadeToolProviderregistered 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@Toolmethod onArcadeToolProvider, which delegates to the . - The response is returned as JSON.
Run the application
Make sure your environment variables are set, then start the application:
Maven
./mvnw spring-boot:runTest the endpoint with curl:
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:
{
"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:
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-starterto your dependencies and setarcade.api-keyin your configuration. The starter creates anArcadeClientbean you can inject anywhere. @Toolannotations bridge Arcade and Spring AI: Annotate methods on a@Serviceclass and pass it toChatClient.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
- Add more : Browse the MCP server catalog and add
@Toolmethods for GitHub, Google Docs, Notion, and more. - Add authentication: In production, resolve
userIdfrom your authentication system instead of a static configuration property. See Secure auth in production for best practices. - Stream responses: Replace
.call()with.stream()on theChatClientto stream responses using Server-Sent Events .
Complete code
pom.xml (full file)
<?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)
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)
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)
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) {}
}