Saturday, October 31, 2026

Artificial Intelligence Simplified - Part 2: Connect Your Java based AI Chatbot to an External Service

“I cannot access www.example.com. Can you check whether it is available?”

In Part 1, we built Pixel, a local Java chatbot using Ollama and Qwen3 0.6B. Pixel could discuss a problem. Now, let’s enable it to investigate.

We will call Google Public DNS’s external JSON API, then contact the website over HTTPS. There is no additional service to build, host, or run. The existing Ollama container still supplies the model.

The model interprets the request. Java validates and executes the action. External systems supply the evidence.

What we are building

Qwen proposes a website check as structured JSON. Java validates that proposal, queries public DNS, and checks the HTTPS homepage. The immediate result is formatted in Java without a second model call.

Google’s API reports public DNS results. The HTTPS request originates from the computer running Pixel and uses that computer’s DNS resolution. Neither proves worldwide availability.

Step 1: Prepare the project

Continue in the pixel-cli project from Part 1. Keep JDK 17, the Gradle Wrapper, the existing Jackson dependency, build.gradle, and settings.gradle. Keep the existing application main class as Pixel.

Start Ollama and verify the model:

BASH · SCROLL TO READ
docker start pixel-ollama
docker exec pixel-ollama ollama list

If needed:

BASH · SCROLL TO READ
docker exec -it pixel-ollama ollama pull qwen3:0.6b

The model setup from Part 1 remains unchanged. On a CPU, model interpretation can take longer than the network checks.

Use public websites for this exercise. The hostname is sent to Google Public DNS and the website is contacted from your computer. Hostname syntax validation does not prevent a hostname from resolving to an internal address. This is a local CLI tutorial; exposing it as a shared server would require enforced destination and outbound network restrictions.

Step 2: Try the external API

Windows PowerShell:

POWERSHELL · SCROLL TO READ
Invoke-RestMethod -Uri "https://dns.google/resolve?name=www.example.com&type=A"

macOS/Linux:

BASH · SCROLL TO READ
curl "https://dns.google/resolve?name=www.example.com&type=A"

Status is a DNS response code, not an HTTP status. A value of zero means the DNS query completed without a DNS error; inspect Answer to determine whether it contains the requested address records. We query A records for IPv4 and AAAA records for IPv6.

Step 3: Give each class one clear responsibility

Rather than placing the conversation, model integration, and network operations in one class, we will use seven small classes. Java 17 records provide immutable values for model decisions and DNS answers.

FileResponsibility
Pixel.javaEntry point; constructs and connects objects
Conversation.javaTerminal input, history, and action dispatch
ModelDecision.javaValidates the model’s proposed action and arguments
OllamaClient.javaRequests structured decisions from Ollama
WebsiteChecker.javaValidates the hostname and coordinates the checks
GoogleDnsClient.javaCalls the external public DNS API
HttpsChecker.javaChecks the homepage from the local network

Create all seven files directly inside src/main/java. They intentionally use the default package to stay compatible with Part 1’s Pixel main class. Replace the old Pixel.java; do not append a second class of the same name. No Spring application, local HTTP server, or additional framework is required.

The JavaDocs describe each class’s contract. Inline comments explain decisions such as retaining the real result in history, validating JSON field types, disabling redirects, and closing response streams.

Pixel.java

JAVA · SCROLL TO READ
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.Scanner;

/**
 * Entry point and composition root: creates the application objects and connects them.
 * No model interpretation or website-checking logic belongs in this class.
 */
public final class Pixel {
    private Pixel() { }

    /**
     * Starts the interactive CLI using the selected Ollama model.
     * @param args optional first argument: model name; defaults to qwen3:0.6b
     */
    public static void main(String[] args) {
        String model = args.length == 0 ? "qwen3:0.6b" : args[0];
        String base = System.getenv().getOrDefault(
                "OLLAMA_URL", "http://127.0.0.1:11434").replaceAll("/+$", "");

        // A configured ObjectMapper and HttpClient can be reused across requests.
        ObjectMapper json = new ObjectMapper();
        HttpClient http = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .followRedirects(HttpClient.Redirect.NEVER)
                .build();
        OllamaClient ollama = new OllamaClient(
                http, json, URI.create(base + "/api/chat"), model);
        WebsiteChecker checker = new WebsiteChecker(
                new GoogleDnsClient(http, json), new HttpsChecker(http));

        System.out.println("Pixel | model: " + model);
        System.out.println("Describe a public website-access problem.");
        System.out.println("Commands: /reset, /exit");
        try (Scanner input = new Scanner(System.in)) {
            new Conversation(ollama, checker).run(input);
        }
    }
}

Conversation.java

JAVA · SCROLL TO READ
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Scanner;

/** Owns terminal interaction, bounded conversation history, and action dispatch. */
public final class Conversation {
    private final OllamaClient ollama;
    private final WebsiteChecker checker;
    private final List<Map<String, String>> history = new ArrayList<>();

    /**
     * Creates a conversation with its model and permitted capability.
     * @param ollama structured model client
     * @param checker implementation of the single supported external action
     */
    public Conversation(OllamaClient ollama, WebsiteChecker checker) {
        this.ollama = ollama;
        this.checker = checker;
    }

    /**
     * Reads messages until /exit, end of input, or interruption.
     * A failed turn is reported without adding a fabricated result to history.
     * @param input terminal input; the caller owns and closes this Scanner
     */
    public void run(Scanner input) {
        while (true) {
            System.out.print("\nYou: ");
            if (!input.hasNextLine()) break;
            String text = input.nextLine().trim();
            if (text.equalsIgnoreCase("/exit")) break;
            if (text.equalsIgnoreCase("/reset")) {
                history.clear();
                System.out.println("Pixel: Conversation cleared.");
                continue;
            }
            if (text.isBlank()) continue;
            if (text.length() > 1500) {
                System.out.println("Please use 1,500 characters or fewer.");
                continue;
            }
            try {
                System.out.println("Pixel is preparing a reply...");
                ModelDecision decision = ollama.decide(history, text);
                // Only this predefined action can reach the checking capability.
                String answer = decision.action().equals("chat")
                        ? decision.reply() : checker.check(decision.hostname());
                System.out.println("\nPixel: " + answer);
                history.add(Map.of("role", "user", "content", text));
                history.add(Map.of("role", "assistant", "content", answer));
                // Retain four complete turns, including the actual observed result.
                while (history.size() > 8) history.subList(0, 2).clear();
            } catch (InterruptedException exception) {
                Thread.currentThread().interrupt(); // Preserve cancellation intent.
                break;
            } catch (IOException | IllegalArgumentException exception) {
                System.out.println("Pixel: I could not complete that request. "
                        + exception.getMessage());
            }
        }
        System.out.println("Goodbye!");
    }
}

ModelDecision.java

JAVA · SCROLL TO READ
import com.fasterxml.jackson.databind.JsonNode;

/**
 * Validated proposal from the language model. A proposal is data, never executable code.
 * @param action either chat or check_website
 * @param hostname hostname for a check; empty for chat
 * @param reply conversational response; empty for a check
 */
public record ModelDecision(String action, String hostname, String reply) {
    /**
     * Enforces the field combinations supported by this application.
     * The JSON schema helps generation, but Java remains the validation boundary.
     * @throws IllegalArgumentException if fields are missing or inconsistent
     */
    public ModelDecision {
        if (action == null || hostname == null || reply == null) {
            throw new IllegalArgumentException("Missing decision fields.");
        }
        boolean chat = action.equals("chat") && hostname.isEmpty() && !reply.isBlank();
        boolean check = action.equals("check_website") && !hostname.isBlank()
                && hostname.length() <= 253 && reply.isEmpty();
        if (!chat && !check) {
            throw new IllegalArgumentException("Unsupported action or arguments.");
        }
    }

    /**
     * Converts JSON into a decision only after checking its structure and field types.
     * @param node parsed model response
     * @return a validated immutable proposal
     * @throws IllegalArgumentException for an unexpected response shape
     */
    public static ModelDecision fromJson(JsonNode node) {
        if (node == null || !node.isObject() || node.size() != 3) {
            throw new IllegalArgumentException("Invalid model response.");
        }
        return new ModelDecision(text(node, "action"), text(node, "hostname"),
                text(node, "reply"));
    }

    /** Reads a string without coercing a number, boolean, or missing value into text. */
    private static String text(JsonNode node, String field) {
        JsonNode value = node.get(field);
        if (value == null || !value.isTextual()) {
            throw new IllegalArgumentException("Missing or invalid field: " + field);
        }
        return value.asText();
    }
}

OllamaClient.java

JAVA · SCROLL TO READ
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Calls Ollama to translate conversation into a structured proposal.
 * This class cannot perform website checks; it only returns a ModelDecision.
 */
public final class OllamaClient {
    private static final String SYSTEM_PROMPT = """
            You are Pixel, a friendly IT help-desk assistant.
            Return only JSON matching the supplied schema.
            If the user cannot access a specific website, asks whether it is
            accessible, or asks you to check it:
              action = "check_website"
              hostname = hostname only, without scheme, path, or port
              reply = ""
            Use recent conversation context when the reference is clear.
            If no hostname is known, ask for it using the chat action.
            Otherwise:
              action = "chat"
              hostname = ""
              reply = a concise helpful response
            Never invent website-check results or claim to execute commands.
            Never ask for passwords or recovery codes.
            """;
    private static final String SCHEMA = """
            {
              "type":"object",
              "properties":{
                "action":{"type":"string","enum":["chat","check_website"]},
                "hostname":{"type":"string"},
                "reply":{"type":"string"}
              },
              "required":["action","hostname","reply"],
              "additionalProperties":false
            }
            """;
    private final HttpClient http;
    private final ObjectMapper json;
    private final URI endpoint;
    private final String model;

    /**
     * Receives dependencies explicitly, avoiding hidden network configuration.
     * @param http reusable HTTP client
     * @param json JSON serializer and parser
     * @param endpoint Ollama chat endpoint, owned by application configuration
     * @param model installed Ollama model name
     */
    public OllamaClient(HttpClient http, ObjectMapper json, URI endpoint, String model) {
        this.http = http;
        this.json = json;
        this.endpoint = endpoint;
        this.model = model;
    }

    /**
     * Requests and validates one model decision without mutating conversation history.
     * @param history completed user/assistant turns
     * @param input current user message
     * @return validated proposal for the application to dispatch
     * @throws IOException on transport errors or invalid JSON
     * @throws InterruptedException if the calling thread is interrupted
     * @throws IllegalArgumentException if the generated decision is invalid
     */
    public ModelDecision decide(List<Map<String, String>> history, String input)
            throws IOException, InterruptedException {
        List<Map<String, String>> messages = new ArrayList<>();
        messages.add(Map.of("role", "system", "content",
                SYSTEM_PROMPT + "\nSchema:\n" + SCHEMA));
        messages.addAll(history);
        messages.add(Map.of("role", "user", "content", input));

        // Supplying the schema as format constrains output structure, not intent.
        String body = json.writeValueAsString(Map.of(
                "model", model, "messages", messages,
                "format", json.readTree(SCHEMA), "stream", false, "think", false,
                "options", Map.of("temperature", 0, "num_ctx", 4096,
                        "num_predict", 300)));
        HttpRequest request = HttpRequest.newBuilder(endpoint)
                .timeout(Duration.ofMinutes(3))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body)).build();
        var response = http.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200) {
            throw new IOException("Ollama returned HTTP " + response.statusCode());
        }
        var envelope = json.readTree(response.body());
        if (envelope == null) {
            throw new IOException("Ollama returned an empty JSON response.");
        }
        String content = envelope.path("message").path("content").asText("");
        // The envelope is Ollama's response; content contains the model's JSON.
        return ModelDecision.fromJson(json.readTree(content));
    }
}

WebsiteChecker.java

JAVA · SCROLL TO READ
import java.io.IOException;
import java.util.Locale;

/**
 * Coordinates hostname validation, public DNS lookups, and an HTTPS request.
 * This capability has no dependency on Ollama or terminal conversation history,
 * making it a natural candidate for later exposure through an MCP adapter.
 */
public final class WebsiteChecker {
    private final GoogleDnsClient dns;
    private final HttpsChecker https;

    /**
     * @param dns external DNS API client
     * @param https HTTPS homepage checker
     */
    public WebsiteChecker(GoogleDnsClient dns, HttpsChecker https) {
        this.dns = dns;
        this.https = https;
    }

    /**
     * Performs a bounded sequence of checks and formats the immediate evidence.
     * Both record types are queried because a website can support IPv4, IPv6, or both.
     * An API transport failure aborts the turn; it is not evidence of a website outage.
     * @param value hostname proposed by the model
     * @return DNS findings and, when an address is found, the HTTPS result
     * @throws IllegalArgumentException for unsupported hostname syntax
     * @throws IOException if the DNS API request or response fails
     * @throws InterruptedException if either external operation is interrupted
     */
    public String check(String value) throws IOException, InterruptedException {
        String hostname = validateHostname(value);
        System.out.println("[Action] Public DNS lookup: " + hostname);
        GoogleDnsClient.Answer ipv4 = dns.lookup(hostname, "A");
        GoogleDnsClient.Answer ipv6 = dns.lookup(hostname, "AAAA");
        String statuses = "DNS status codes: A=" + ipv4.status()
                + ", AAAA=" + ipv6.status() + ".";
        if (!ipv4.hasAddress() && !ipv6.hasAddress()) {
            return "Google Public DNS returned no usable A or AAAA address for "
                    + hostname + ". " + statuses
                    + " This does not establish domain-registration status.";
        }
        System.out.println("[Action] HTTPS request: " + hostname);
        return "Google Public DNS returned an address. " + statuses + "\n"
                + https.check(hostname);
    }

    /**
     * Accepts ASCII DNS hostnames, excluding schemes, paths, ports, and IP literals.
     * This validates syntax only: a valid name can still resolve to a private address.
     * It is not a public-destination filter or a defence against DNS rebinding.
     * @param value hostname supplied by the model
     * @return trimmed, lowercase hostname
     * @throws IllegalArgumentException if the hostname syntax is unsupported
     */
    private static String validateHostname(String value) {
        if (value == null) throw new IllegalArgumentException("Provide a hostname.");
        String hostname = value.trim().toLowerCase(Locale.ROOT);
        if (hostname.length() > 253 || !hostname.matches(
                "(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}")) {
            throw new IllegalArgumentException(
                    "Use a hostname such as www.example.com, without a scheme, path, or port.");
        }
        return hostname;
    }
}

GoogleDnsClient.java

JAVA · SCROLL TO READ
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

/** Calls Google Public DNS's external JSON API for address-record evidence. */
public final class GoogleDnsClient {
    private final HttpClient http;
    private final ObjectMapper json;

    /**
     * @param http reusable HTTP client with redirects disabled
     * @param json JSON response parser
     */
    public GoogleDnsClient(HttpClient http, ObjectMapper json) {
        this.http = http;
        this.json = json;
    }

    /**
     * Captures both DNS status and whether a requested address record was returned.
     * @param status DNS response code, distinct from an HTTP status code
     * @param hasAddress true if a successful response contains the requested record
     */
    public record Answer(int status, boolean hasAddress) { }

    /**
     * Looks up A or AAAA records without treating an empty answer as non-registration.
     * @param hostname hostname already validated by WebsiteChecker
     * @param type A for IPv4 or AAAA for IPv6
     * @return DNS status and address-record presence
     * @throws IOException if the API cannot be reached or its response is invalid
     * @throws InterruptedException if interrupted during the request
     * @throws IllegalArgumentException if an unsupported record type is requested
     */
    public Answer lookup(String hostname, String type)
            throws IOException, InterruptedException {
        int recordType = switch (type) {
            case "A" -> 1;
            case "AAAA" -> 28;
            default -> throw new IllegalArgumentException("Use A or AAAA.");
        };
        String encoded = URLEncoder.encode(hostname, StandardCharsets.UTF_8);
        URI uri = URI.create("https://dns.google/resolve?name=" + encoded + "&type=" + type);
        HttpRequest request = HttpRequest.newBuilder(uri)
                .timeout(Duration.ofSeconds(10))
                .header("Accept", "application/json").GET().build();
        var response = http.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200) {
            throw new IOException("Google DNS API returned HTTP " + response.statusCode());
        }
        JsonNode result = json.readTree(response.body());
        if (result == null || !result.path("Status").isIntegralNumber()) {
            throw new IOException("Unexpected DNS API response.");
        }
        int status = result.path("Status").intValue();
        if (status == 0) {
            // CNAME records may also appear; count only the requested address type.
            for (JsonNode answer : result.path("Answer")) {
                if (answer.path("type").asInt() == recordType
                        && !answer.path("data").asText("").isBlank()) {
                    return new Answer(status, true);
                }
            }
        }
        return new Answer(status, false);
    }
}

HttpsChecker.java

JAVA · SCROLL TO READ
import javax.net.ssl.SSLException;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;

/**
 * Checks the HTTPS homepage from the computer running Pixel.
 * Uses local DNS resolution; Google DNS answers are not used as connection targets.
 */
public final class HttpsChecker {
    private final HttpClient http;

    /**
     * Requires redirects to be disabled so every reported status describes this host.
     * @param http reusable client with Redirect.NEVER
     * @throws IllegalArgumentException if the client follows redirects
     */
    public HttpsChecker(HttpClient http) {
        if (http.followRedirects() != HttpClient.Redirect.NEVER) {
            throw new IllegalArgumentException("The checker must not follow redirects.");
        }
        this.http = http;
    }

    /**
     * Sends a GET request and reports the observed response or connection failure.
     * @param hostname hostname validated by WebsiteChecker
     * @return factual result; does not claim global availability
     * @throws InterruptedException if the request is interrupted
     */
    public String check(String hostname) throws InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(URI.create("https://" + hostname + "/"))
                .timeout(Duration.ofSeconds(10))
                .header("User-Agent", "PixelWebsiteChecker/1.0").GET().build();
        try {
            var response = http.send(request, HttpResponse.BodyHandlers.ofInputStream());
            // Do not parse HTML or feed website content into the language model.
            // Closing the stream avoids retaining/downloading the complete page;
            // some body bytes may already have arrived with the response headers.
            try (var body = response.body()) {
                int code = response.statusCode();
                String explanation;
                if (code >= 200 && code < 300) {
                    explanation = "The homepage responded successfully from this computer's network.";
                } else if (code >= 300 && code < 400) {
                    explanation = "The website redirected. The destination was not checked.";
                } else if (code == 401 || code == 403 || code == 429) {
                    explanation = "The website restricted the request. Browser access may differ.";
                } else if (code >= 500) {
                    explanation = "The website responded with a server error.";
                } else {
                    explanation = "The website responded, but its homepage returned an HTTP error.";
                }
                return hostname + " returned HTTP " + code + ". " + explanation;
            }
        } catch (HttpTimeoutException exception) {
            return hostname + ": the HTTPS request timed out. This does not establish a global outage.";
        } catch (SSLException exception) {
            return hostname + ": a trusted HTTPS connection could not be established.";
        } catch (IOException exception) {
            return hostname + ": public DNS returned an address, but the HTTPS request failed "
                    + "from this computer. Local DNS or network conditions may differ.";
        }
    }
}

Step 4: Build and launch

From the existing pixel-cli project root, run:

Windows PowerShell:

POWERSHELL · SCROLL TO READ
.\gradlew.bat installDist
.\build\install\pixel-cli\bin\pixel-cli.bat

macOS/Linux:

BASH · SCROLL TO READ
./gradlew installDist
./build/install/pixel-cli/bin/pixel-cli

Only the Java CLI and the existing Ollama container need to run. There is no website-checking server to start.

Step 5: Ask Pixel to investigate

TEXT · SCROLL TO READ
You: I cannot access www.example.com. Can you check it?
Pixel is preparing a reply...
[Action] Public DNS lookup: www.example.com
[Action] HTTPS request: www.example.com

Pixel then prints the observed result. A successful response might resemble:

TEXT · SCROLL TO READ
Pixel: Google Public DNS returned an address. DNS status codes: A=0, AAAA=0.
www.example.com returned HTTP 200. The homepage responded successfully from this computer's network.

This is illustrative output; actual DNS results and HTTP responses vary.

The model proposes a decision resembling:

JSON · SCROLL TO READ
{"action":"check_website","hostname":"www.example.com","reply":""}

ModelDecision validates the field combination. Conversation dispatches the supported action. WebsiteChecker coordinates the checks. No model-generated endpoint, HTTP method, or command is executed.

Now test clarification:

TEXT · SCROLL TO READ
/reset
I cannot access a website.

Pixel should ask for the hostname. Reply:

TEXT · SCROLL TO READ
The website is www.example.com.

History provides the context for that answer. The CLI retains four completed turns, including the actual check result. Follow-up explanations are model-generated, so they can still be mistaken. A small model may also select the wrong action; structured JSON constrains the response shape, not the correctness of its interpretation.

Step 6: Check a name that should not resolve

TEXT · SCROLL TO READ
Please check pixel-demo.invalid.

The .invalid namespace is reserved for invalid names. Under normal public DNS behaviour, no address should be returned. Pixel displays the DNS statuses without claiming to have checked domain registration.

If the DNS API fails to respond, Pixel reports a failed request. An API transport error is not evidence that the target website is down. In this example the A and AAAA lookups run sequentially; an API failure aborts the turn rather than silently ignoring incomplete evidence.

Understanding the evidence

ObservationWhat it establishes
Public DNS returns an A or AAAA recordGoogle’s resolver supplied an address record
No address records returnedNo usable address was found by these queries; inspect DNS statuses
HTTP 2xxThe requested homepage responded successfully
HTTP 3xxA redirect was returned; its destination remains unchecked
HTTP 401/403/429The website restricted this request
HTTP 5xxThe website returned a server error
Timeout or connection failureThis computer could not complete the HTTPS check
TLS failureJava could not establish a trusted HTTPS connection

DNS resolution, domain registration, and application availability are different questions. A functioning homepage does not establish that login, search, or checkout works. A website may block Java while allowing a browser. Corporate proxy settings and TLS trust can also differ between clients.

The example checks HTTPS port 443 and the homepage only. It does not follow redirects, execute JavaScript, authenticate, or inspect HTML. Timeouts apply to the HTTP requests; they are not a strict overall wall-clock limit for the complete turn or the platform’s local DNS resolution.

Step 7: Stop the demo

Exit Pixel:

TEXT · SCROLL TO READ
/exit

Then stop Ollama:

BASH · SCROLL TO READ
docker stop pixel-ollama

What Have We Established?

Pixel now connects a natural-language request to an external API and a live website check. The model interprets the request, Java controls execution, and external systems supply the evidence.

Now imagine applying this approach across an ecosystem of data-accessor services. One service retrieves inventory, another provides order details, and a third reports delivery status. Pixel could become a conversational entry point across these capabilities.

A customer-support colleague might ask:

“Why has order 12345 not arrived, and is a replacement available?”

An extended Pixel could retrieve the order, check its shipment, query replacement inventory, and assemble an answer grounded in those results. Building that workflow would require additional orchestration: selecting multiple tools, passing identifiers between calls, and handling incomplete or conflicting responses.

The same idea applies to an IT service desk:

“I cannot access the employee portal. Check whether it responds, and log a ticket if the problem persists.”

Pixel could call a website-checking service, ask for the missing details, and invoke a ticket-management microservice. That service would create the ticket and return its reference number. Because creating a ticket changes a system, the workflow should verify the user’s identity, confirm the submission details, and prevent duplicate tickets if a request is retried.

Other possibilities are close to everyday work:

  • Travel operations: Retrieve booking details, supplier status, and cancellation rules to help investigate a disrupted itinerary.
  • Engineering support: Correlate deployment history, service health, and incident records to investigate a reported failure.
  • Employee assistance: Query approved knowledge sources, check an existing request, and submit a new service request when needed.

The architectural opportunity is to make existing capabilities accessible through conversation. Each service continues to own its data, business rules, and authorization. Pixel helps users discover and combine those capabilities, while the application controls what can be called and what information can be returned.

We have implemented one small example. The broader possibility is a conversational application that can retrieve evidence, coordinate services, and help complete useful work.

In Part 3, we will expose our website-checking capability through MCP - and explore how selected methods on ordinary Java objects can become discoverable tools through a reusable abstraction.

References

Friday, October 30, 2026

Artificial Intelligence (AI) Simplified - Part 1: Build a Local AI Chatbot with Java

“My laptop cannot connect to the office Wi-Fi.”

An IT support conversation often begins with a statement like this. Before suggesting a solution, somebody must understand the problem and ask the right questions.

Could we build an assistant that starts that conversation on our own computer?

Let’s create Pixel, a conversational IT help-desk assistant using Java, Ollama, and a small Qwen model.

This is the first article in a three-part series:

  1. Conversation: Build a local chatbot with a Java command-line interface.
  2. Action: Connect Pixel to external services to investigate website availability.
  3. Integration: Expose selected Java methods as reusable tools through MCP.

What We Are Building

ComponentResponsibility
Java CLIAccepts input, retains recent conversation history, and displays responses
OllamaRuns the model and exposes an HTTP API
Qwen3 0.6BGenerates responses from the supplied conversation

Ollama runs in Docker. The Java application runs directly on your computer and communicates with Ollama over HTTP.

Pixel can discuss common IT problems. This first version does not inspect your computer, check live website status, or create tickets. Its replies are generated suggestions.

Java manages the conversation. Ollama runs the model. The model generates a response.

Step 1: Prepare Your Environment

Install the following:

  • Docker Desktop, or Docker Engine on Linux.
  • JDK 17, including the Java compiler.
  • Gradle 8.14.3, available from the Gradle releases page.

The Gradle version is retained from the original tutorial so that the series uses a consistent build setup; it is not a claim about the latest release.

Use these practical resource allowances:

ResourceStarting point
Disk spaceAt least 5 GB free for images, the model, dependencies, and build files
Memory8 GB RAM or more, with several gigabytes available to Docker
ComputeA modern CPU; GPU acceleration is optional and requires separate configuration
InternetRequired for initial downloads

These are planning allowances, not guaranteed minimum requirements. Runtime memory exceeds the model's downloaded file size, and other applications also consume resources.

Open PowerShell on Windows, or Terminal on macOS/Linux, and verify your tools:

BASH · SCROLL TO READ
java -version
javac -version
gradle --version
docker version

A Note About Response Speed

This tutorial uses CPU-based inference and does not configure GPU passthrough. On a CPU, expect noticeable pauses. The first response can take longer while the model loads into memory.

Response time also depends on processor capability, available memory, conversation length, and competing workloads. Start with short conversations and evaluate performance on your own hardware.

Step 2: Start Ollama

Run this command in PowerShell or Bash:

BASH · SCROLL TO READ
docker run -d --name pixel-ollama -p 127.0.0.1:11434:11434 -v pixel-models:/root/.ollama ollama/ollama:latest
ArgumentPurpose
-dRuns the container in the background
--name pixel-ollamaAssigns a recognizable container name
-p 127.0.0.1:11434:11434Exposes the API through your computer's loopback address
-v pixel-models:/root/.ollamaPreserves downloaded models in a Docker volume

Check that the container is running:

BASH · SCROLL TO READ
docker ps --filter name=pixel-ollama

If you previously created this container, start it instead of creating another:

BASH · SCROLL TO READ
docker start pixel-ollama

The example uses the latest image tag for convenience. For repeatable team deployments, record and pin a tested image version or digest.

Step 3: Download and Check the Model

Download Qwen3 0.6B:

BASH · SCROLL TO READ
docker exec -it pixel-ollama ollama pull qwen3:0.6b

Verify that it is available:

BASH · SCROLL TO READ
docker exec pixel-ollama ollama list

You should see qwen3:0.6b.

Now check that the model responds:

BASH · SCROLL TO READ
docker exec -it pixel-ollama ollama run qwen3:0.6b "Introduce yourself as Pixel, a friendly IT support assistant. Use two sentences."

The wording will vary. Allow extra time for the first response.

The downloaded model remains in the Docker volume, so you do not need to download it every time you restart the container.

Step 4: Create the Java Project

Create and enter the project directory:

BASH · SCROLL TO READ
mkdir pixel-cli
cd pixel-cli

Create the source directory.

Windows PowerShell:

POWERSHELL · SCROLL TO READ
New-Item -ItemType Directory -Force src/main/java

macOS/Linux:

BASH · SCROLL TO READ
mkdir -p src/main/java

Create settings.gradle in the pixel-cli directory:

GROOVY · SCROLL TO READ
rootProject.name = 'pixel-cli'

Create build.gradle alongside it:

GROOVY · SCROLL TO READ
plugins {
    id 'application'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.2'
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

application {
    mainClass = 'Pixel'
}

We use Java's built-in HTTP client to communicate with Ollama. Jackson serializes request objects into JSON and parses the response. The Jackson version is retained from the original project for continuity.

Generate the Gradle Wrapper:

BASH · SCROLL TO READ
gradle wrapper --gradle-version 8.14.3

Subsequent builds use the project's selected Gradle version through its wrapper.

Step 5: Write Pixel

Create src/main/java/Pixel.java using the code below. If you followed the original version, replace the file with this documented version. The executable code is unchanged; JavaDocs and inline comments explain the conversation loop, JSON request, response handling, and history management.

The class brings together three responsibilities for this introductory example: reading terminal input, communicating with Ollama, and retaining recent conversation context.

JAVA · SCROLL TO READ
import com.fasterxml.jackson.databind.ObjectMapper;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Scanner;

/**
 * A local, conversational IT help-desk assistant using Ollama's chat API.
 *
 * <p>The Java CLI reads terminal input, retains recent conversation history,
 * sends requests to a locally running model, and displays generated replies.
 * It does not inspect the computer, check live services, or execute actions.
 *
 * <p>History exists only in memory and is cleared on reset or application exit.
 */
public class Pixel {

    // Role instructions accompany every request; they guide generation but
    // do not guarantee that the model will always follow them.
    private static final String SYSTEM_PROMPT = """
            You are Pixel, a friendly IT help-desk assistant.
            Help users describe and troubleshoot common IT problems.
            Ask one clarifying question at a time.
            Keep replies concise and easy to understand.
            Never ask for passwords, recovery codes, or other secrets.
            Do not claim to inspect systems, check live service status,
            create tickets, or perform actions.
            You have no tools or access to the user's computer.
            Admit uncertainty and recommend contacting IT when appropriate.
            """;

    // Reuse the configured JSON mapper rather than constructing one per turn.
    private static final ObjectMapper JSON = new ObjectMapper();

    // Reuse the HTTP client and its connection pool across conversation turns.
    // Connection establishment has its own timeout, separate from generation.
    private static final HttpClient HTTP = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    /**
     * Starts the interactive chatbot and handles its conversation lifecycle.
     *
     * <p>Uses qwen3:0.6b unless the first command-line argument selects another
     * installed model. OLLAMA_URL can override the default local API address.
     * Supported commands are /reset and /exit.
     *
     * @param args optional first argument containing an installed model name
     */
    public static void main(String[] args) {
        String model = args.length > 0 ? args[0] : "qwen3:0.6b";

        // Strip trailing slashes so appending /api/chat forms a consistent URL.
        String baseUrl = System.getenv().getOrDefault(
                "OLLAMA_URL",
                "http://127.0.0.1:11434"
        ).replaceAll("/+$", "");

        URI endpoint = URI.create(baseUrl + "/api/chat");

        // Each completed exchange contributes one user and one assistant message.
        List<Map<String, String>> history = new ArrayList<>();

        System.out.println("Pixel | model: " + model);
        System.out.println("Describe an IT issue.");
        System.out.println("Commands: /reset, /exit");

        // Closing the Scanner releases the terminal input resource on exit.
        try (Scanner input = new Scanner(System.in)) {
            while (true) {
                System.out.print("\nYou: ");

                // End of input also ends the conversation, even without /exit.
                if (!input.hasNextLine()) {
                    break;
                }

                String text = input.nextLine().trim();

                if (text.equalsIgnoreCase("/exit")) {
                    break;
                }

                // Reset removes conversational context without restarting the model.
                if (text.equalsIgnoreCase("/reset")) {
                    history.clear();
                    System.out.println("Pixel: Conversation cleared.");
                    continue;
                }

                if (text.isBlank()) {
                    continue;
                }

                // Bound message length; characters are not the same as model tokens.
                if (text.length() > 1500) {
                    System.out.println(
                            "Pixel: Please keep each message "
                                    + "to 1,500 characters or fewer."
                    );
                    continue;
                }

                // Rebuild the request with instructions, history, and the new input.
                // Ollama receives the context explicitly on each call.
                List<Map<String, String>> messages = new ArrayList<>();
                messages.add(message("system", SYSTEM_PROMPT));
                messages.addAll(history);
                messages.add(message("user", text));

                try {
                    // Let Jackson escape input safely instead of concatenating JSON.
                    // Non-streaming mode returns one response after generation.
                    String body = JSON.writeValueAsString(Map.of(
                            "model", model,
                            "messages", messages,
                            "stream", false,
                            "think", false,
                            "options", Map.of(
                                    "temperature", 0.3,
                                    "num_ctx", 4096,
                                    "num_predict", 200
                            )
                    ));

                    // Allow time for CPU-based inference, including initial model loading.
                    HttpRequest request = HttpRequest.newBuilder(endpoint)
                            .timeout(Duration.ofMinutes(3))
                            .header("Content-Type", "application/json")
                            .POST(HttpRequest.BodyPublishers.ofString(body))
                            .build();

                    System.out.println("Pixel is preparing a reply...");

                    HttpResponse<String> response = HTTP.send(
                            request,
                            HttpResponse.BodyHandlers.ofString()
                    );

                    // An HTTP error is an integration failure, not a chatbot reply.
                    if (response.statusCode() != 200) {
                        System.out.println(
                                "Ollama returned HTTP "
                                        + response.statusCode()
                                        + ": " + response.body()
                        );
                        continue;
                    }

                    // Generated text is nested inside Ollama's response envelope.
                    String answer = JSON.readTree(response.body())
                            .path("message")
                            .path("content")
                            .asText("")
                            .trim();

                    if (answer.isEmpty()) {
                        System.out.println(
                                "Pixel: No reply received. Try again."
                        );
                        continue;
                    }

                    System.out.println("\nPixel: " + answer);

                    // Record only a successful, nonempty exchange.
                    history.add(message("user", text));
                    history.add(message("assistant", answer));

                    // Retain at most four completed exchanges.
                    // Remove the oldest pair together to preserve turn ordering.
                    while (history.size() > 8) {
                        history.subList(0, 2).clear();
                    }

                } catch (InterruptedException exception) {
                    // Restore the interruption flag before leaving the input loop.
                    Thread.currentThread().interrupt();
                    System.out.println("Conversation interrupted.");
                    break;
                } catch (Exception exception) {
                    System.out.println(
                            "Could not complete the request: "
                                    + exception.getMessage()
                    );
                    System.out.println(
                            "Check that Ollama is running "
                                    + "and the model is downloaded."
                    );
                }
            }
        }

        System.out.println("Goodbye!");
    }

    /**
     * Creates an immutable message in the shape expected by Ollama's chat API.
     *
     * @param role message role, such as system, user, or assistant
     * @param content instructions, user input, or generated response text
     * @return map containing the role and content fields
     */
    private static Map<String, String> message(
            String role,
            String content
    ) {
        return Map.of("role", role, "content", content);
    }
}

The system prompt describes Pixel's intended role. It does not guarantee correctness or instruction-following. This application has no tools for inspecting the computer or executing actions.

Step 6: Build and Start the Chatbot

From the pixel-cli directory, build an application distribution.

Windows PowerShell:

POWERSHELL · SCROLL TO READ
.\gradlew.bat installDist

Launch Pixel:

POWERSHELL · SCROLL TO READ
.\build\install\pixel-cli\bin\pixel-cli.bat

macOS/Linux:

BASH · SCROLL TO READ
./gradlew installDist

Launch Pixel:

BASH · SCROLL TO READ
./build/install/pixel-cli/bin/pixel-cli

The first wrapper build downloads the selected Gradle distribution if it is not cached, along with the required Java dependencies.

We launch the generated application directly so that it receives terminal input without relying on Gradle to forward it.

You should see:

TEXT · SCROLL TO READ
Pixel | model: qwen3:0.6b
Describe an IT issue.
Commands: /reset, /exit

You:

Step 7: Have a Conversation

Enter:

TEXT · SCROLL TO READ
My Windows laptop cannot connect to the office Wi-Fi.

After Pixel responds, provide more context:

TEXT · SCROLL TO READ
Other people can connect, but my laptop cannot see the network.

The application sends recent conversation messages with each request. This gives the model context for follow-up questions; it is not permanent learning or retraining.

Try asking:

TEXT · SCROLL TO READ
Explain your suggested check in simpler terms.

To start a fresh conversation:

TEXT · SCROLL TO READ
/reset

To exit:

TEXT · SCROLL TO READ
/exit

History is held in memory and disappears when the application exits. Use fictional support scenarios while evaluating the model, and do not enter passwords or other secrets.

Step 8: Understand the Important Settings

SettingPurpose
modelSelects qwen3:0.6b by default
messagesSupplies the system prompt, recent conversation, and new message
stream: falseRequests the generated reply as one non-streaming response
think: falseRequests no separate thinking output for the selected model
temperature: 0.3Encourages less varied responses
num_ctx: 4096Configures the context window in tokens
num_predict: 200Limits generated output in tokens

A lower temperature does not guarantee correctness. Retaining four turns bounds history by message count, not by tokens, so it does not guarantee that every request fits in the context window.

The connection timeout limits connection establishment. The request timeout allows up to three minutes for the HTTP exchange. These settings do not make the model generate faster.

Step 9: Adjust Pixel's Personality

Edit SYSTEM_PROMPT in Pixel.java to add instructions such as:

TEXT · SCROLL TO READ
Be warm and lightly witty, but keep troubleshooting clear.
Explain technical terms when speaking to a beginner.

Rebuild the application.

Windows PowerShell:

POWERSHELL · SCROLL TO READ
.\gradlew.bat installDist

macOS/Linux:

BASH · SCROLL TO READ
./gradlew installDist

Then launch Pixel again.

You can also select a downloaded model through a command-line argument.

Windows PowerShell:

POWERSHELL · SCROLL TO READ
.\build\install\pixel-cli\bin\pixel-cli.bat qwen3:0.6b

macOS/Linux:

BASH · SCROLL TO READ
./build/install/pixel-cli/bin/pixel-cli qwen3:0.6b

To switch models, download the intended model first and replace the argument. Other models can require different request settings or substantially more memory.

Step 10: Stop and Resume

After exiting Pixel, stop Ollama:

BASH · SCROLL TO READ
docker stop pixel-ollama

The downloaded model remains stored in the Docker volume.

Next time, start Ollama:

BASH · SCROLL TO READ
docker start pixel-ollama

Then launch the generated Java application again. Rebuild when you change the code or build configuration.

What Have We Established?

We now have a Java application that holds a conversation with a locally running model, retains recent context, and supplies instructions defining its role.

The code makes each step visible: collect input, assemble conversation context, call the model, display its reply, and retain the completed exchange.

For developers, this is a familiar HTTP integration. For students, it makes the relationship between an application and a language model concrete. For technology leaders, it provides a small, inspectable experiment before a broader investment.

The next step is to obtain current evidence through an external action. In Part 2: Connect Your Java Chatbot to an External Service, Pixel calls Google Public DNS and checks a website's HTTPS response. No additional locally hosted service is required.

When continuing to Part 2, replace Pixel.java and add the other six classes from that article. The project name, Gradle setup, main class, and Ollama container remain consistent.

References

Artificial Intelligence Simplified - Part 2: Connect Your Java based AI Chatbot to an External Service

“I cannot access www.example.com . Can you check whether it is available?” In Part 1 , we built Pixel, a local Java chatbot using Ollam...