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

Author: Vincent Vauban

Original post on Foojay: Read More

Table of Contents

TL;DRPart I — Concepts

Part II — App building (code + explanations)

Part III — Demo

Part IV — Conclusion and extensions

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

The post No Keys, No LLM: Building a Wikidata Definition API with Embabel appeared first on foojay.