-
Notifications
You must be signed in to change notification settings - Fork 0
Your First Agent
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
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
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.
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:
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.
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.
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.
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.
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.
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 transformThe 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 FormattedOutputThe compiler reports: Type mismatch: inferred type is Agent<FormattedOutput, StyledDocument> but Agent<ParsedSpec, ???> was expected. The error is caught before your code ever runs.
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.
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 styleThe 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).
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 runOr click the green play icon next to fun main() in IntelliJ IDEA.
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. |
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.
Getting Started
Core Concepts
Composition Operators
LLM Integration
- Model & Tool Calling
- Tool Error Recovery
- Skill Selection & Routing
- Budget Controls
- Observability Hooks
Guided Generation
Agent Memory
Reference