No Keys, No LLM: Building a Wikidata Definition API with Embabel
AIJava

No Keys, No LLM: Building a Wikidata Definition API with Embabel

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:

  1. starts from a DefinitionRequest(term)
  2. resolves a Wikidata entity ID (Q-id)
  3. fetches entity details
  4. 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: noop

II.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.

Repo: https://github.com/vinny59200/embabel

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

Found a mistake, or something to add? Edit this page on GitHub

Written by

Vincent Vauban

Oracle Ace, VMWare Expert, and Fullstack software developer with over 15 years of experience in Java, Spring, JavaScript, React, and Angular. As a software engineer and Udemy instructor, he brings his expertise to a variety of industries, including banking, …

Join the discussion