Skip to main content

Aether: the harness that turns an LLM into a trustworthy agent

·918 words·5 mins
Michael Antonio Tomaylla
Author
Michael Antonio Tomaylla
From technical complexity to simplicity that creates value

From a bot that replies to an agent that works
#

Let’s be honest: wiring a model to Telegram so it answers messages is an afternoon’s work. The hard part comes later, when you want that bot to stop being a parrot with good diction and start taking care of things for you.

That’s the story of Aether: we take a bot that only replies and keep upgrading it until it becomes an agent with identity, memory and hands. And we do it without magic frameworks, using pieces you already know.

Today’s toolbox:

  • Java 25 and Spring AI 2 as the runtime
  • GLM 5.2 served on NVIDIA as the model
  • Git (one branch per upgrade)
  • Telegram as the agent’s face

Two generations of agents
#

Before writing code it helps to get oriented. Agents have lived through two generations.

The first generation is orchestrated pipelines: you draw the graph and the system walks it. Tools like LangGraph, AutoGen, CrewAI or LlamaIndex shine here. The path is fixed:

step 1: search  →  step 2: summarize  →  step 3: respond

The second generation is the agent loop: you give it a goal and a handful of tools, and the model decides what step to take each turn. It’s the pattern behind assistants like Claude, GPT or Gemini.

Aether is born in the second generation: we don’t draw the path, we let it decide.

The key decision: where does the ChatClient live?
#

The whole architecture boils down to one question: where do we put the ChatClient? The tempting move is to wire Telegram’s input straight to the model and pass the message as-is. That’s Version 0: the engine only replies.

// The brain: a Spring AI ChatClient
@Bean
ChatClient aether(ChatClient.Builder b) {
    return b.build();
}

// The face: for every message that arrives from Telegram…
String reply = aether.prompt()
        .user(input)   // ← chat text
        .call()
        .content();

respond(chatId, reply);   // → back to the phone

The ChatClient is the engine and Telegram is the face. It works… as a toy. To use it daily it’s missing four things, and the moment you ask for something real, it shows:

  1. Identity — no stable personality.
  2. Context — it doesn’t know who it works for or where it is.
  3. Action — it doesn’t know what it can touch or when to stop.

Replying to messages is easy. Taking charge of something asks for more pieces.

Harness engineering
#

This is the central idea of the post: harness engineering.

Spring AI provides the runtime. The harness decides how it’s used.

Designing the harness means deciding what the agent sees, what it can do, what it remembers and when it asks for permission. The model is the muscle; the harness is everything that turns it into something trustworthy. And the nice part is that it’s only a few lines.

The 5 pillars of the harness
#

Each pillar lives in its own branch. You can run git checkout pilar-1-soul and watch the agent gain superpowers live. All the code is in the repo: github.com/darkmtrance/aether.

Pillar 1 · Heart — SOUL.md gives it personality
#

🌿 Branch: pilar-1-soul

We write down how we want it to act when we’re not watching. The file is re-read every turn, so you can tune its character without recompiling anything.

String reply = aether.prompt()
        .system(harness.readSoul())   // ← SOUL.md, live
        .user(input)
        .call()
        .content();

Pillar 2 · Viewer — USER.md + AGENTS.md open its eyes
#

🌿 Branch: pilar-2-context

Live context. The agent needs to know who it works for, where it is and what day it is. A CallAdvisor injects that context on every turn.

public ChatClientResponse adviseCall(
        ChatClientRequest req, CallAdvisorChain chain) {
    return chain.nextCall(req.mutate()
            .prompt(req.prompt().augmentSystemMessage(
                    context()))   // date + USER.md + AGENTS.md
            .build());
}

Pillar 3 · Hands — SKILLS.md gives it real tools
#

🌿 Branch: pilar-3-skills

The model chooses the skill, but the actual command comes from the SKILLS.md catalog (an allowlist), never from the model. This way the agent runs git, docker or mvn with no shell in between and zero injection.

@Tool(description = "Runs a skill from the catalog")
public String runSkill(@ToolParam String name) {
    String command = readCatalog().get(name);
    var p = new ProcessBuilder(tokenize(command)).start();
    // git, docker, mvn — no shell, zero injection
    ...
}

Pillar 4 · Backpack — MEMORY.md remembers for it
#

🌿 Branch: pilar-4-memory

Memory generates itself, turn by turn. The conversation thread lives in ChatMemory, and what happened yesterday is still here today.

var memory = MessageWindowChatMemory.builder()
        .maxMessages(20).build();   // the thread

builder.defaultAdvisors(
            contextAdvisor,
            MessageChatMemoryAdvisor.builder(memory).build())
       .defaultTools(skillTools, memoryTools);   // MEMORY.md

Pillar 5 · Shield — human in the loop
#

🌿 Branch: pilar-5-shield

Destructive actions go through an explicit approval gate. The agent proposes, you authorize. And the real deletion isn’t triggered by the LLM: the app triggers it when you confirm.

@Tool(description = "Requests deleting a file. Does NOT delete it")
public String requestFileDeletion(String path) {
    approvals.registerPendingDeletion(path);
    return "⚠️ Type \"Confirm\" to proceed.";
}
// the real deletion is fired by the app, not the LLM

The result
#

From a few lines to an agent with a soul (SOUL.md), eyes (USER.md + AGENTS.md), hands (SKILLS.md), memory (MEMORY.md) and a brake (the “Confirm” gate).

What’s interesting isn’t the model — that one is swappable — but the harness around it. That harness is yours: it defines how much you trust the agent and how much you keep for yourself. The model holds the wheel only when and how you decide.

Author: human <me>
Co-authored-by: AI — held the harness, didn't take the wheel.