An AI agent refers to a software entity that can perceive, reason, and act autonomously to achieve specific goals using artificial intelligence techniques like natural language processing, machine learning, or reasoning systems.
I developed an AI agent for Telex which takes a regex pattern and provides a human-friendly explanation on the type(s) of string matched by that regex pattern. The inspiration for this agent lie in an API I developed just before this, where I had to use regex for some natural language processing (you can check out the project here). Though I had learnt regex previously, it felt like I was seeing it for the first time. Regex is just like that. So when Telex sought for more AI agents for their platform, I decided to develop this agent.
Here's how I did it using Java, Spring AI and Spring Boot
Initial Setup
1. Spring Boot Project Initialization
I initialize your project using the initializer provided by Spring. Notice that I included Spring Web and Open AI in the dependencies
2. Set Up API Credentials
In my application.properties files, I set up Spring AI to use my API credentials (my API key). I got a free Google's Gemini API key using Google AI studio. Here's how my application.properties file is set up:
spring.config.import=classpath:AI.properties
spring.application.name=regexplain
spring.ai.openai.api-key = ${GEMINI_API_KEY}
spring.ai.openai.base-url = https://generativelanguage.googleapis.com/v1beta/openai
spring.ai.openai.chat.completions-path = /chat/completions
spring.ai.openai.chat.options.model = gemini-2.5-pro
The first line imports the file that contains my API key. It is important that you don't expose your API key to the public. The file is located in the same folder as application.properties.
3. First Project Run
Using my package manager(maven), I installed the needed dependencies. Then I ran my main class to be sure that everything works as it should. If you everything right up to this point, yours should run without errors. If you encounter any error, look it up on Google to find a fix.
A2A Request and Response Model
Before I go into the implementation, let's talk a bit on the structure of an A2A-compliant request and response. The A2A protocol adheres to the standard JSON-RPC 2.0 structures for requests and responses.
All method calls are encapsulated in a request object, which looks like this:
{
jsonrpc: "2.0";
method: String;
id: String | Integer;
params: Message;
}
The response is a bit similar:
{
jsonrpc: "2.0";
id: String | Integer | null;
result?: Task | Message | null;
error?: JSONRPCError;
}
The response ID MUST be the same as the request ID.
For more information on the A2A protocol, check out the A2A protocol docs.
That is the general structure of the request and response. I developed this agent for use in the Telex platform, so some of my implementation may be specific to Telex.
Now, to the implementation. I created a folder called model, where I'll store my models. The request model class called A2ARequest looks like this:
public class A2ARequest {
private String id;
private RequestParamsProperty params;
public A2ARequest(String id, RequestParamsProperty params) {
this.id = id;
this.params = params;
}
// getters and setters
}
The RequestParamsProperties class represent the structure of the information contained in params. It looks like this:
public class RequestParamsProperty {
private HistoryMessage message;
private String messageId;
public RequestParamsProperty(HistoryMessage message, String messageId) {
this.message = message;
this.messageId = messageId;
}
// getters and setter
}
HistoryMessage class looks like this:
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class HistoryMessage {
private String kind;
private String role;
private List<MessagePart> parts;
private String messageId;
private String taskId;
public HistoryMessage() {}
public HistoryMessage(String role, List<MessagePart> parts, String messageId, String taskId) {
this.kind = "message";
this.role = role;
this.parts = parts;
this.messageId = messageId;
this.taskId = taskId;
}
// getters and setters
}
The annotations are so that spring knows what to include in the JSON representation of the request and response. If a property doesn't exist in the request, it should ignore it and set it to null in the class. If a property is set to null, it shouldn't include it in the response.
MessagePart class looks like this:
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MessagePart {
private String kind;
private String text;
private List<MessagePart> data;
public MessagePart(String kind, String text, List<MessagePart> data) {
this.kind = kind;
this.text = text;
this.data = data;
}
// getters and setters
}
That's all the classes needed to represent the request structure received from Telex. Now to create a model for my response, and all supporting classes needed to represent my response
@JsonInclude(JsonInclude.Include.NON_NULL)
public class A2AResponse {
private final String jsonrpc;
@JsonInclude(JsonInclude.Include.ALWAYS)
private String id;
private Result result;
private CustomError error;
public A2AResponse() {
this.jsonrpc = "2.0";
}
public A2AResponse(String id, Result result, CustomError error) {
this.jsonrpc = "2.0";
this.id = id;
this.result = result;
this.error = error;
}
//getters and setters
}
Result class:
public class Result {
private String id;
private String contextId;
private TaskStatus status;
private List<Artifact> artifacts;
private List<HistoryMessage> history;
private String kind;
public Result() {}
public Result(String id, String contextId, TaskStatus status, List<Artifact> artifacts, List<HistoryMessage> history, String task) {
this.id = id;
this.contextId = contextId;
this.status = status;
this.artifacts = artifacts;
this.history = history;
this.kind = task;
}
// getters and setters
}
CustomError class:
public class CustomError {
private int code;
private String message;
private Map<String, String> data;
public CustomError(int code, String message, Map<String, String> data) {
this.code = code;
this.message = message;
this.data = data;
}
// getters and setters
}
TaskStatus class:
@JsonInclude(JsonInclude.Include.NON_NULL)
public class TaskStatus {
private String state;
private Instant timestamp;
private HistoryMessage message;
public TaskStatus() {}
public TaskStatus(String state, Instant timestamp, HistoryMessage message) {
this.state = state;
this.timestamp = timestamp;
this.message = message;
}
// getters and setters
}
Artifact class:
public class Artifact {
private String artifactId;
private String name;
private List<MessagePart> parts; // come back to review that type
public Artifact() {}
public Artifact(String artifactId, String name, List<MessagePart> parts) {
this.artifactId = artifactId;
this.name = name;
this.parts = parts;
}
// getters and setters
}
The A2A protocol also includes something called the agent card. I created a model for it also.
public class AgentCard {
private String name;
private String description;
private String url;
private Map<String, String> provider;
private String version;
private Map<String, Boolean> capabilities;
private List<String> defaultInputModes;
private List<String> defaultOutputModes;
private List<Map<String, Object>> skills;
public AgentCard() {
this.provider = new HashMap<>();
this.capabilities = new HashMap<>();
this.skills = new ArrayList<>();
}
// getters and setters
}
That's all for models. Moving on...
The Service Class
What my agent does is to get a regex string and then send it to OpenAI's API with a predefined prompt. The service class handles communicating with OpenAI, sending the prompt and receiving the response. I created another folder called service which is where my service class resides. This is how I wrote my service class:
@Service
public class RegExPlainService {
private ChatClient chatClient;
RegExPlainService(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@Tool(name = "regexplain", description = "An agent that explains what type of string a regex pattern matches")
public String generateResponse(String regex) {
return chatClient
.prompt("Give me a simple explanation of the type of string matched by this regex pattern: %s. No validating statements from you. Just straight to the point".formatted(regex))
.call()
.content();
}
}
The service annotation allows Spring Boot to perform a service injection into your controller. The Tool annotation marks the method as an agent tool which can be autonomously called if the agent is to be extended to include that functionality. It's not needed right now, though.
The Controller
The controller exposes the agent using REST API. In this case, I have two endpoints, a GET endpoint and a POST endpoint. I created my controller in a folder called controller. This is the implementation:
@RestController
public class RegExPlainController {
private final RegExPlainService regexplainService;
@Autowired
RegExPlainController (RegExPlainService regexplainService) {
this.regexplainService = regexplainService;
}
@GetMapping("/a2a/agent/regexplain/.well-known/agent.json")
public ResponseEntity<AgentCard> getAgentCard () {
AgentCard agentCard = new AgentCard();
agentCard.setName("regexplain");
agentCard.setDescription("An agent that provides a simple explanation of the type of string a regex pattern matches");
agentCard.setUrl("regexplain-production.up.railway.app/api");
agentCard.setProvider("Bituan", null);
agentCard.setVersion("1.0");
agentCard.setCapabilities(false, false, false);
agentCard.setDefaultInputModes(List.of("text/plain"));
agentCard.setDefaultOutputModes(List.of("application/json", "text/plain"));
agentCard.setSkill("skill-001", "Explain Regex", "Provides a simple explanation of the type of string a regex pattern matches",
List.of("text/plain"), List.of("text/plain"), List.of());
return ResponseEntity.ok(agentCard);
}
@PostMapping("/a2a/agent/regexplain")
public ResponseEntity<A2AResponse> explainRegex (@RequestBody A2ARequest request) {
String regexRequest;
String responseText;
// return 403 if parameter is invalid
try {
regexRequest = request.getParams().getMessage().getParts().get(0).getText();
} catch (Exception e) {
CustomError error = new CustomError(-32603, "Invalid Parameter", Map.of("details", e.getMessage()));
A2AResponse errorResponse = new A2AResponse(null, null, error);
return ResponseEntity.status(HttpStatusCode.valueOf(403)).body(errorResponse);
}
// return error 500 if call to service fails
try {
responseText = regexplainService.generateResponse(regexRequest);
} catch (Exception e) {
CustomError error = new CustomError(-32603, "Internal Error", Map.of("details", e.getMessage()));
A2AResponse errorResponse = new A2AResponse(null, null, error);
return ResponseEntity.internalServerError().body(errorResponse);
}
// response building
A2AResponse response = new A2AResponse();
response.setId(request.getId());
// response building -> result building
Result result = new Result();
result.setId(UUID.randomUUID().toString());
result.setContextId(UUID.randomUUID().toString());
result.setKind("task");
// response building -> result building -> status building
TaskStatus status = new TaskStatus();
status.setState("completed");
status.setTimestamp(Instant.now());
// response building -> result building -> status building -> message building
HistoryMessage message = new HistoryMessage();
message.setRole("agent");
message.setParts(List.of(new MessagePart("text", responseText, null)));
message.setKind("message");
message.setMessageId(UUID.randomUUID().toString());
// response building -> result building -> status building contd
status.setMessage(message);
// response building -> result building -> artifact building
List<Artifact> artifacts = new ArrayList<>();
Artifact artifact = new Artifact();
artifact.setArtifactId(UUID.randomUUID().toString());
artifact.setName("regexplainerResponse");
artifact.setParts(List.of(new MessagePart("text", responseText, null)));
artifacts.add(artifact);
//response building -> result building -> history building
List<HistoryMessage> history = new ArrayList<>();
//response building -> result building contd
result.setStatus(status);
result.setArtifacts(artifacts);
result.setHistory(history);
// response building contd
response.setResult(result);
return ResponseEntity.ok(response);
}
}
The GET endpoint uses a route part of the A2A protocol standard for getting the agent card. The agent card is a description of the agent and what it can do.
The POST endpoint takes an A2A-compliant request and executes the agent, before returning an appropriate response.
Conclusion
That's it. That's how I wrote Regexplain.
With this, you can build your AI agent from scratch and make it A2A-compliant. Or, at least, I hope this has given you some insight on how to go about developing your A2A-compliant AI agent in Java. I also wrote about how to integrate this agent in Telex. You can read about it here.
That's all for now, thanks for reading. Bye!

Top comments (0)