TL;DR
- I built a Spring Boot 4 API that defines terms via Wikidata.
- The app is fully reproducible: no API keys and no model installation needed.
- Embabel orchestrates the pipeline as a sequence of actions to achieve the goal DefinitionResult.
- The logs show planning, execution, and typed object binding—the most useful part for teaching agentic flows.
No Keys, No LLM: Building a Wikidata Definition API with Embabel
I wanted a demo that is simple, reproducible, and still shows agentic orchestration in a way that’s easy to explain on video.
So I built a small Spring Boot 4 app that exposes a single endpoint:
- GET /api/wiki/define?term=...
It returns a compact JSON “definition” fetched from Wikidata (no authentication, no API keys).
The important part: I used Embabel to orchestrate the workflow, even though the workflow is deterministic and does not need an LLM.
Part I — Concepts
I.1 Embabel
Embabel is an agent framework for the JVM. I like to think of it as a way to model a workflow as:
- Actions: steps the agent can execute
- Goals: what the workflow should produce
- State / facts: typed objects available at each moment
- Planning: decide which actions to run and in which order to achieve the goal
In practice, that means I don’t call methods in a fixed chain. I provide an initial input (a domain object), tell Embabel what type I want as the result, and Embabel plans and runs the required actions.
I.2 Spring AI (even in a “no LLM” demo)
Spring AI provides an abstraction layer for interacting with chat models (and other AI components) using Spring-friendly APIs.
In this project, I implemented a tiny NOOP chat model. It’s not used to generate anything. It exists because the Embabel starter expects a default model entry to be configured at startup.
This kept the demo:
- fully runnable without credentials,
- focused on orchestration,
- and easy to extend later with a real model.
I.3 Role of Embabel in this application
A reasonable question is: “What’s the point of using Embabel just to query a REST API?”
The REST call is not the point. The point is to demonstrate a workflow that:
- starts from a DefinitionRequest(term)
- resolves a Wikidata entity ID (Q-id)
- fetches entity details
- builds a typed DefinitionResult
Embabel makes these steps explicit, typed, and observable, and it can re-plan as the state evolves. That’s a much better foundation than packing everything into one big service method—especially when the demo grows.
I.4 Wikidata: definition and why it’s ideal for demos
Wikidata is a public, open knowledge base. It’s perfect for demos because:
- it’s online,
- it’s free to read,
- and the APIs are easy to call from a small Java project.
I used two endpoints:
- wbsearchentities to search for a term and retrieve the most relevant Q-id
- Special:EntityData/{QID}.json to fetch structured entity data (labels, descriptions, and Wikipedia sitelinks)
This gives a nice “definition API” in a few lines of code, with zero setup for viewers.
Part II — App building (code + explanations)
II.1 Maven setup (
pom.xml)
I used Spring Boot 4.0.3 with Java 25, and Embabel 0.3.4.
Because this is Boot 4, I added spring-boot-starter-restclient so RestClient.Builder is auto-configured.
I also forced Jackson 2 compatibility (spring-boot-jackson2) and excluded spring-boot-starter-json, because the Embabel starter wiring in this setup expects Jackson2ObjectMapperBuilder.
<?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>4.0.3</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>wikidemo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>wikidemo</name> <properties> <java.version>25</java.version> <embabel-agent.version>0.3.4</embabel-agent.version> </properties> <dependencies> <!-- REST endpoint --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-json</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-restclient</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-jackson2</artifactId> </dependency> <!-- Embabel agent platform --> <dependency> <groupId>com.embabel.agent</groupId> <artifactId>embabel-agent-starter</artifactId> <version>${embabel-agent.version}</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
II.2 Configuration (application.yml)
I set the server port and configured the default Embabel model name to noop.
spring:
application:
name: wikidemo
server:
port: 8080
embabel:
models:
default-llm: noopII.3 App launcher + Embabel enablement + NOOP LLM registration
The application entrypoint enables agent scanning using @EnableAgents, then registers a “noop” model so the platform boots without external dependencies.
package com.vv.wikidemo; import com.embabel.agent.config.annotation.EnableAgents; import com.embabel.agent.spi.LlmService; import com.embabel.agent.spi.support.springai.SpringAiLlmService; import com.vv.wikidemo.service.NoopChatModel; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication @EnableAgents public class WikiDemoApplication { public static void main( String[] args ) { SpringApplication.run( WikiDemoApplication.class, args ); } @Bean public LlmService<?> noopLlm() { return new SpringAiLlmService( "noop", // model name (must match embabel.models.default-llm) "noop-provider", // provider label (any string) new NoopChatModel() ); } }
II.4 The NOOP ChatModel (Spring AI)
This is intentionally minimal. If Embabel ever calls it, it returns a predictable message.
package com.vv.wikidemo.service; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.prompt.Prompt; import java.util.List; public class NoopChatModel implements ChatModel { @Override public ChatResponse call( Prompt prompt ) { var msg = new AssistantMessage( "NOOP LLM: no real LLM configured (this demo doesn't need one)." ); return new ChatResponse( List.of( new Generation( msg ) ) ); } }
II.5 Domain model (Java records)
I used records for the request, intermediate agent objects, and final result.
package com.vv.wikidemo.model; public record DefinitionRequest(String term) { } public record DefinitionResult( String term, String entityId, String label, String description, String wikidataUrl, String wikipediaUrl ) { } public record WikidataEntityDetails( String label, String description, String wikipediaTitle ) {} public record WikidataEntityId(String id) { }
The key idea is that Embabel “stores” and “reuses” these typed objects during execution. They become the agent’s working memory.
II.6 Repository: Wikidata calls with RestClient
The repository is responsible for the data access logic only:
- search for the best match (Q-id)
- fetch details for that Q-id
- build stable URLs
I kept DTO mappings minimal and resilient with @JsonIgnoreProperties(ignoreUnknown = true).
package com.vv.wikidemo.repository; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.vv.wikidemo.model.WikidataEntityDetails; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Repository; import org.springframework.web.client.RestClient; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Optional; @Repository public class WikidataRepository { private final RestClient wikidata; public WikidataRepository( RestClient.Builder builder ) { this.wikidata = builder .baseUrl( "https://www.wikidata.org" ) .defaultHeader( HttpHeaders.USER_AGENT, "wikidemo/0.0.1 (SpringBoot+Embabel demo)" ) .build(); } /** * Step 1: find the first matching Q-id for a term */ public Optional<SearchItem> searchFirst( String term ) { SearchResponse response = wikidata.get() .uri( uriBuilder -> uriBuilder .path( "/w/api.php" ) .queryParam( "action", "wbsearchentities" ) .queryParam( "search", term ) .queryParam( "language", "en" ) .queryParam( "format", "json" ) .queryParam( "limit", "1" ) .build() ) .retrieve() .body( SearchResponse.class ); if ( response == null || response.search == null || response.search.isEmpty() ) { return Optional.empty(); } return Optional.ofNullable( response.search.get( 0 ) ); } /** * Step 2: fetch label/description (and Wikipedia title if present) from Special:EntityData */ public WikidataEntityDetails fetchEntityDetails( String entityId ) { EntityDataResponse data = wikidata.get() .uri( "/wiki/Special:EntityData/{id}.json", entityId ) .retrieve() .body( EntityDataResponse.class ); if ( data == null || data.entities == null || !data.entities.containsKey( entityId ) ) { return new WikidataEntityDetails( null, null, null ); } Entity entity = data.entities.get( entityId ); String label = valueOf( entity.labels, "en" ); String desc = valueOf( entity.descriptions, "en" ); String wikiTitle = (entity.sitelinks != null && entity.sitelinks.containsKey( "enwiki" )) ? entity.sitelinks.get( "enwiki" ).title : null; return new WikidataEntityDetails( label, desc, wikiTitle ); } public static String wikidataUrl( String entityId ) { return "https://www.wikidata.org/wiki/" + entityId; } public static String wikipediaUrl( String title ) { if ( title == null || title.isBlank() ) { return null; } String normalized = title.replace( ' ', '_' ); return "https://en.wikipedia.org/wiki/" + URLEncoder.encode( normalized, StandardCharsets.UTF_8 ); } private static String valueOf( Map<String, LangValue> map, String lang ) { if ( map == null ) { return null; } LangValue lv = map.get( lang ); return lv == null ? null : lv.value; } // --- DTOs for JSON mapping (minimal fields only) --- @JsonIgnoreProperties( ignoreUnknown = true ) static class SearchResponse { @JsonProperty( "search" ) public List<SearchItem> search; } @JsonIgnoreProperties( ignoreUnknown = true ) public static class SearchItem { @JsonProperty( "id" ) public String id; @JsonProperty( "label" ) public String label; @JsonProperty( "description" ) public String description; } @JsonIgnoreProperties( ignoreUnknown = true ) static class EntityDataResponse { @JsonProperty( "entities" ) public Map<String, Entity> entities; } @JsonIgnoreProperties( ignoreUnknown = true ) static class Entity { @JsonProperty( "labels" ) public Map<String, LangValue> labels; @JsonProperty( "descriptions" ) public Map<String, LangValue> descriptions; @JsonProperty( "sitelinks" ) public Map<String, Sitelink> sitelinks; } @JsonIgnoreProperties( ignoreUnknown = true ) static class LangValue { @JsonProperty( "value" ) public String value; } @JsonIgnoreProperties( ignoreUnknown = true ) static class Sitelink { @JsonProperty( "title" ) public String title; } }
II.7 The Embabel agent (actions + goal)
The agent defines the workflow. Each method is a step (@Action). The final step is tagged as a goal (@AchievesGoal) because it produces the desired output type DefinitionResult.
package com.vv.wikidemo.service; import com.embabel.agent.api.annotation.AchievesGoal; import com.embabel.agent.api.annotation.Action; import com.embabel.agent.api.annotation.Agent; import com.vv.wikidemo.model.DefinitionRequest; import com.vv.wikidemo.model.DefinitionResult; import com.vv.wikidemo.model.WikidataEntityDetails; import com.vv.wikidemo.model.WikidataEntityId; import com.vv.wikidemo.repository.WikidataRepository; import org.springframework.web.server.ResponseStatusException; import static org.springframework.http.HttpStatus.NOT_FOUND; @Agent( description = "Define a word using Wikidata (no LLM, no auth)" ) public class WikidataDefinitionAgent { private final WikidataRepository repo; public WikidataDefinitionAgent( WikidataRepository repo ) { this.repo = repo; } @Action public WikidataEntityId findEntityId( DefinitionRequest request ) { var hit = repo.searchFirst( request.term() ) .orElseThrow( () -> new ResponseStatusException( NOT_FOUND, "No Wikidata entity found for term: " + request.term() ) ); return new WikidataEntityId( hit.id ); } @Action public WikidataEntityDetails fetchDetails( WikidataEntityId id ) { return repo.fetchEntityDetails( id.id() ); } @Action @AchievesGoal( description = "Return a Wikidata-based definition" ) public DefinitionResult build( DefinitionRequest request, WikidataEntityId id, WikidataEntityDetails details ) { String wikidataUrl = WikidataRepository.wikidataUrl( id.id() ); String wikipediaUrl = WikidataRepository.wikipediaUrl( details.wikipediaTitle() ); // If Wikidata doesn't have an English label/description, you still get a stable entity link. return new DefinitionResult( request.term(), id.id(), details.label(), details.description(), wikidataUrl, wikipediaUrl ); } }
I like this structure because it stays small and readable. More importantly, it becomes easy to extend later:
add a disambiguation action,
- add a caching action,
- add alternative paths,
- add optional post-processing.
II.8 Service: running the agent via AgentInvocation
The service is the bridge between the web layer and Embabel. It creates an AgentInvocation and calls it with a DefinitionRequest.
package com.vv.wikidemo.service; import com.embabel.agent.api.invocation.AgentInvocation; import com.embabel.agent.core.AgentPlatform; import com.vv.wikidemo.model.DefinitionRequest; import com.vv.wikidemo.model.DefinitionResult; import org.springframework.stereotype.Service; @Service public class WikiService { private final AgentPlatform agentPlatform; private final AgentInvocation<DefinitionResult> invocation; public WikiService( AgentPlatform agentPlatform ) { this.agentPlatform = agentPlatform; this.invocation = AgentInvocation .builder( agentPlatform ) .build( DefinitionResult.class ); } public DefinitionResult define( String term ) { return invocation.invoke( new DefinitionRequest( term ) ); } }
II.9 Controller: a single endpoint
The controller stays boring on purpose. All the interesting logic is in the agent and repository.
package com.vv.wikidemo.controller; import com.vv.wikidemo.model.DefinitionResult; import com.vv.wikidemo.service.WikiService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping( "/api/wiki" ) public class WikiController { private final WikiService wikiService; public WikiController( WikiService wikiService ) { this.wikiService = wikiService; } @GetMapping( "/define" ) public DefinitionResult define( @RequestParam( "term" ) String term ) { return wikiService.define( term ); } }
Part III — Demo
III.1 Curl request
curl --request get --url 'http://localhost:8080/api/wiki/define?term=kafka'III.2 Response
{ "term": "kafka", "entityId": "Q16235208", "label": "Apache Kafka", "description": "open source data stream processing platform", "wikidataUrl": "https://www.wikidata.org/wiki/Q16235208", "wikipediaUrl": "https://en.wikipedia.org/wiki/Apache_Kafka" }
This is intentionally “small JSON”: label + description + canonical links.
III.3 Logs: the agentic part
These logs are the best part to show on screen, because they reveal Embabel’s planning and execution.
21:35:05.039 [tomcat-handler-2] INFO Embabel - [goofy_mcclintock] created 21:35:05.039 [tomcat-handler-2] INFO Embabel - [goofy_mcclintock] object added: DefinitionRequest 21:35:05.046 [task-1] INFO Embabel - [goofy_mcclintock] formulated plan: com.vv.wikidemo.service.WikidataDefinitionAgent.findEntityId -> com.vv.wikidemo.service.WikidataDefinitionAgent.fetchDetails -> com.vv.wikidemo.service.WikidataDefinitionAgent.build 21:35:05.047 [task-1] INFO Embabel - [goofy_mcclintock] executing action ... findEntityId 21:35:05.745 [task-1] INFO Embabel - [goofy_mcclintock] executed action ... findEntityId in PT0.686S 21:35:05.743 [task-1] INFO Embabel - [goofy_mcclintock] object bound it:WikidataEntityId 21:35:05.749 [task-1] INFO Embabel - [goofy_mcclintock] formulated plan: com.vv.wikidemo.service.WikidataDefinitionAgent.fetchDetails -> com.vv.wikidemo.service.WikidataDefinitionAgent.build 21:35:05.749 [task-1] INFO Embabel - [goofy_mcclintock] executing action ... fetchDetails 21:35:06.187 [task-1] INFO Embabel - [goofy_mcclintock] executed action ... fetchDetails in PT0.437S 21:35:06.186 [task-1] INFO Embabel - [goofy_mcclintock] object bound it:WikidataEntityDetails 21:35:06.189 [task-1] INFO Embabel - [goofy_mcclintock] formulated plan: com.vv.wikidemo.service.WikidataDefinitionAgent.build 21:35:06.190 [task-1] INFO Embabel - [goofy_mcclintock] executing action ... build 21:35:06.191 [task-1] INFO Embabel - [goofy_mcclintock] object bound it:DefinitionResult 21:35:06.196 [task-1] INFO Embabel - [goofy_mcclintock] goal ... achieved in PT1.164...
What stands out:
- Embabel starts with DefinitionRequest
- It formulates a plan (sequence of actions)
- It executes each action
- It binds the produced objects (WikidataEntityId, WikidataEntityDetails, then DefinitionResult)
- It declares the goal achieved
This is the “agentic” angle: Embabel is not just calling methods—it's planning against typed state.
Part IV — Conclusion and extensions
This application intentionally starts simple. It’s a demo designed to be reproduced in minutes.
However, the Embabel structure is already useful because it’s an orchestrator. Extending the system becomes a matter of adding actions and (optionally) conditions, not rewriting a monolithic service method.
Here are extensions that make the demo evolve naturally:
1) Disambiguation
Instead of limit=1, fetch the top N hits and add an action to pick the best match. For example:
- exact label match
- description keyword match
- “instance of” filtering (person vs concept vs product)
2) Multi-language
Add lang to DefinitionRequest and propagate it into:
- wbsearchentities&language=...
- selecting labels/descriptions by language
3) Confidence score
Add a ConfidenceScore record and an action that computes a score based on:
- match quality
- label similarity
- number of aliases
- presence of sitelinks
Return it to consumers to make the API safer to use.
4) Caching and rate limiting
Add an action that checks a cache before querying Wikidata. This is a classic production step and it fits nicely as an independent action.
5) Multi-source enrichment
Add an alternative source for definitions:
- DBpedia
- Wikipedia summary API
- internal enterprise knowledge base
Embabel becomes more valuable as the number of sources increases, because orchestration becomes a first-class concept.
6) Optional LLM post-processing (when needed)
A good, minimal LLM use case is last-mile text rewriting:
- convert the Wikidata description into a more “dictionary-like” sentence
- add examples
- translate to French
- generate a short TL;DR
This keeps the retrieval deterministic and makes the LLM optional, which is often a safer architecture.
Udemy Spring Certification Practice Course: https://www.udemy.com/course/spring-professional-certification-6-full-tests-2v0-7222-a/?referralCode=04B6ED315B27753236AC
Study Guide For Spring: https://spring-book.mystrikingly.com