Skip to content

Your First Agent

skobeltsyn edited this page Mar 28, 2026 · 1 revision

Your First Agent -- A Step-by-Step Tutorial

In this tutorial you will build a text processing pipeline from scratch. By the end, you will have three agents chained together with compile-time type safety -- and a clear understanding of every DSL element that makes it work.

Time required: ~15 minutes Prerequisites: Installation and Setup completed


Goal

We will build a pipeline that takes raw text, parses it into a structured specification, transforms the specification into formatted output, and applies final styling. Each stage is a separate agent. The agents are composed using the then operator into a type-safe Pipeline.

RawText  -->  [parse]  -->  ParsedSpec  -->  [transform]  -->  FormattedOutput

Later, we will add a third stage:

RawText --> [parse] --> ParsedSpec --> [transform] --> FormattedOutput --> [style] --> StyledDocument

Step 1: Define Your Types

Every agent in Agents.KT is typed: Agent<IN, OUT>. Before writing any agent logic, define the data classes that flow between them.

Create a file src/main/kotlin/tutorial/Types.kt:

package tutorial

/** Raw input text, straight from the user. */
data class RawText(val content: String)

/** Parsed specification: the raw text split into named fields. */
data class ParsedSpec(
    val title: String,
    val items: List<String>,
)

/** Formatted output with a rendered string. */
data class FormattedOutput(val rendered: String)

/** Final styled document ready for display. */
data class StyledDocument(val text: String)

These are plain Kotlin data classes. There is nothing framework-specific about them. Agents.KT does not require your types to extend any base class or implement any interface.

Why data classes? They give you equals(), hashCode(), toString(), and copy() for free -- making agents easy to test and debug.


Step 2: Create a Single Agent

Create a file src/main/kotlin/tutorial/ParseAgent.kt:

package tutorial

import agents_engine.core.agent

val parse = agent<RawText, ParsedSpec>("parse") {
    skills {
        skill<RawText, ParsedSpec>("parse-text", "Splits raw text into a title and a list of items") {
            implementedBy { input ->
                val lines = input.content.lines().map { it.trim() }.filter { it.isNotEmpty() }
                ParsedSpec(
                    title = lines.firstOrNull() ?: "Untitled",
                    items = lines.drop(1),
                )
            }
        }
    }
}

Let's break down every DSL element:

agent<RawText, ParsedSpec>("parse") { ... }

The top-level agent function creates an Agent<IN, OUT>. The type parameters are the contract: this agent accepts RawText and must produce ParsedSpec. The string "parse" is the agent's name, used in logs and error messages.

The trailing lambda is the agent's configuration block. Everything inside it configures this agent.

skills { ... }

The skills block declares what this agent can do. An agent can have multiple skills, but at least one skill must produce the agent's OUT type (ParsedSpec in this case). The framework validates this at construction time -- if no skill returns the agent's output type, you get an immediate error, not a runtime surprise.

skill<RawText, ParsedSpec>("parse-text", "Splits raw text...") { ... }

Each skill is also typed: skill<IN, OUT>. The first argument is the skill name. The second is a human-readable description (used when an LLM needs to choose between skills). The trailing lambda configures the skill.

implementedBy { input -> ... }

This marks the skill as a pure Kotlin skill -- no LLM involved. The lambda receives the input and must return the output. It is a regular Kotlin function. You can call any Kotlin code, use libraries, access databases, make HTTP calls -- whatever you need.

The alternative is tools("toolName"), which marks a skill as LLM-driven (agentic). We will cover that in Model & Tool Calling. For now, implementedBy is all you need.


Step 3: Invoke the Agent

Agents implement operator fun invoke, so you call them like functions:

fun main() {
    val input = RawText(
        """
        Shopping List
        Milk
        Eggs
        Bread
        """.trimIndent()
    )

    val result = parse(input)
    println(result)
    // ParsedSpec(title=Shopping List, items=[Milk, Eggs, Bread])
}

That is it. parse(input) calls the agent. The agent selects the appropriate skill (here there is only one), executes it, and returns the typed result. The return type is ParsedSpec -- the compiler knows this. No casting, no type checking, no Any.


Step 4: Chain Two Agents

Now create a second agent that transforms ParsedSpec into FormattedOutput.

Create src/main/kotlin/tutorial/TransformAgent.kt:

package tutorial

import agents_engine.core.agent

val transform = agent<ParsedSpec, FormattedOutput>("transform") {
    skills {
        skill<ParsedSpec, FormattedOutput>("format-spec", "Renders a parsed spec as a numbered list") {
            implementedBy { spec ->
                val body = spec.items.mapIndexed { i, item ->
                    "  ${i + 1}. $item"
                }.joinToString("\n")
                FormattedOutput("${spec.title}\n$body")
            }
        }
    }
}

Now chain the two agents with then:

import agents_engine.composition.pipeline.then

val pipeline = parse then transform

The then operator creates a Pipeline<RawText, FormattedOutput>. The compiler verifies that the output type of parse (ParsedSpec) matches the input type of transform (ParsedSpec). If they don't match, you get a compile error -- not a runtime crash three hours into a production run.

What happens if the types don't align? Try this:

// This will NOT compile:
val broken = parse then style  // parse outputs ParsedSpec, style expects FormattedOutput

The compiler reports: Type mismatch: inferred type is Agent<FormattedOutput, StyledDocument> but Agent<ParsedSpec, ???> was expected. The error is caught before your code ever runs.

Invoke the pipeline

Pipelines also implement operator fun invoke:

val result = pipeline(RawText("Shopping List\nMilk\nEggs\nBread"))
println(result)
// FormattedOutput(rendered=Shopping List
//   1. Milk
//   2. Eggs
//   3. Bread)

The pipeline feeds the input through parse, then passes the ParsedSpec output to transform, and returns the final FormattedOutput. Each agent runs in sequence.


Step 5: Add a Third Stage

Create src/main/kotlin/tutorial/StyleAgent.kt:

package tutorial

import agents_engine.core.agent

val style = agent<FormattedOutput, StyledDocument>("style") {
    skills {
        skill<FormattedOutput, StyledDocument>("apply-style", "Wraps output in a styled document frame") {
            implementedBy { formatted ->
                val border = "=".repeat(40)
                StyledDocument("$border\n${formatted.rendered}\n$border")
            }
        }
    }
}

Chain all three:

import agents_engine.composition.pipeline.then

val fullPipeline = parse then transform then style

The type flows through the entire chain:

parse:     Agent<RawText, ParsedSpec>
transform: Agent<ParsedSpec, FormattedOutput>
style:     Agent<FormattedOutput, StyledDocument>

fullPipeline: Pipeline<RawText, StyledDocument>

Every boundary is compiler-checked. The pipeline's input is RawText (from the first agent) and its output is StyledDocument (from the last agent).


Step 6: Run and Verify

Here is the complete, runnable program. Create src/main/kotlin/tutorial/Main.kt:

package tutorial

import agents_engine.core.agent
import agents_engine.composition.pipeline.then

// --- Types ---

data class RawText(val content: String)
data class ParsedSpec(val title: String, val items: List<String>)
data class FormattedOutput(val rendered: String)
data class StyledDocument(val text: String)

// --- Agents ---

val parse = agent<RawText, ParsedSpec>("parse") {
    skills {
        skill<RawText, ParsedSpec>("parse-text", "Splits raw text into title and items") {
            implementedBy { input ->
                val lines = input.content.lines().map { it.trim() }.filter { it.isNotEmpty() }
                ParsedSpec(
                    title = lines.firstOrNull() ?: "Untitled",
                    items = lines.drop(1),
                )
            }
        }
    }
}

val transform = agent<ParsedSpec, FormattedOutput>("transform") {
    skills {
        skill<ParsedSpec, FormattedOutput>("format-spec", "Renders a parsed spec as a numbered list") {
            implementedBy { spec ->
                val body = spec.items.mapIndexed { i, item ->
                    "  ${i + 1}. $item"
                }.joinToString("\n")
                FormattedOutput("${spec.title}\n$body")
            }
        }
    }
}

val style = agent<FormattedOutput, StyledDocument>("style") {
    skills {
        skill<FormattedOutput, StyledDocument>("apply-style", "Wraps output in a styled border") {
            implementedBy { formatted ->
                val border = "=".repeat(40)
                StyledDocument("$border\n${formatted.rendered}\n$border")
            }
        }
    }
}

// --- Pipeline ---

val pipeline = parse then transform then style

// --- Entry Point ---

fun main() {
    val input = RawText(
        """
        Shopping List
        Milk
        Eggs
        Bread
        Coffee
        """.trimIndent()
    )

    val result = pipeline(input)
    println(result.text)
}

Expected output:

========================================
Shopping List
  1. Milk
  2. Eggs
  3. Bread
  4. Coffee
========================================

Run it with:

./gradlew run

Or click the green play icon next to fun main() in IntelliJ IDEA.


What You Learned

This tutorial covered the foundational concepts of Agents.KT:

Concept What It Does
Agent<IN, OUT> The core abstraction. One input type, one output type, one job.
agent<IN, OUT>("name") { } Top-level DSL function that creates and configures an agent.
skills { } Block that declares an agent's capabilities.
skill<IN, OUT>("name", "description") { } Declares a single skill with its own typed contract.
implementedBy { input -> output } Marks a skill as pure Kotlin -- no LLM required.
operator fun invoke Agents and pipelines are called like functions: agent(input).
then Infix operator that chains agents into a Pipeline<IN, OUT>.
Compile-time type checking The compiler verifies that adjacent agents in a pipeline have matching types.

Next Steps

Now that you can build and compose agents, explore these topics:

  • Skills & Knowledge -- Add knowledge entries to skills, use multiple skills per agent, and learn about LLM-driven skills with tools().
  • Composition: Pipeline -- Deep dive into pipelines, including pipelines of pipelines and combining with other composition operators.
  • Architecture Overview -- Understand the full framework architecture, the protocol stack, and how agents fit into larger systems.
  • Model & Tool Calling -- Connect agents to LLMs via Ollama and define tools the LLM can call.

Clone this wiki locally