Skip to content

Skills and Knowledge

skobeltsyn edited this page May 4, 2026 · 3 revisions

Skills and Knowledge

Skills are the units of behavior in Agents.KT. An agent does nothing on its own -- all its capabilities come from its skills. A skill is either a pure Kotlin function or an LLM-driven capability backed by tools and knowledge. This page covers skill definition, knowledge entries, and how the framework exposes skill metadata to the LLM.


What is a Skill?

Skill<IN, OUT> is a named, described capability with typed input and output:

class Skill<IN, OUT>(
    val name: String,
    val description: String,
    val inType: KClass<*>,
    val outType: KClass<*>,
)
  • name -- Unique within the agent. Used for routing, logging, and LLM descriptions.
  • description -- Mandatory. Tells the LLM (and human readers) what this skill does. Used in skill routing prompts and toLlmDescription().
  • inType / outType -- KClass references captured via reified generics. The agent uses outType during validate() to ensure at least one skill matches the agent's OUT type.

A skill is itself callable: it implements operator fun invoke(input: IN): OUT.


Defining Skills Inline

The most common pattern. Define skills directly inside an agent's skills { } block:

val agent = agent<String, String>("formatter") {
    skills {
        skill<String, String>("uppercase", "Convert text to uppercase") {
            implementedBy { it.uppercase() }
        }
        skill<String, String>("lowercase", "Convert text to lowercase") {
            implementedBy { it.lowercase() }
        }
    }
}

The skill<IN, OUT>(name, description) { } function inside SkillsBuilder creates the skill, applies the configuration block, and registers it with the agent in a single step.


Defining Skills Standalone

When a skill is complex, shared across agents, or you want to reference it separately, define it standalone using the top-level skill() function, then add it with unaryPlus (+):

val codeReview = skill<String, String>("review", "Review code for issues") {
    knowledge("style-guide", "Project coding standards") {
        File("docs/style.md").readText()
    }
    implementedBy { code ->
        // review logic
        "Looks good: $code"
    }
}

val agent = agent<String, String>("reviewer") {
    skills {
        +codeReview  // unaryPlus registers the standalone skill
    }
}

The + operator (unaryPlus) on Skill<IN, OUT> inside a SkillsBuilder block registers the skill just like the inline version. This is pure syntax sugar -- the result is identical.


implementedBy -- Pure Kotlin Skills

implementedBy { } sets the skill's implementation to a Kotlin lambda. The skill is not agentic -- no LLM is involved. The lambda receives the input and returns the output directly.

skill<Int, Int>("double", "Double the input value") {
    implementedBy { it * 2 }
}

Properties after calling implementedBy:

  • isAgentic is false
  • implementation holds the lambda
  • toolNames is null

Pure Kotlin skills execute instantly. They are the right choice when the behavior is deterministic and does not need LLM reasoning.

val tokenizer = skill<String, List<String>>("tokenize", "Split text into tokens") {
    implementedBy { input ->
        input.split("\\s+".toRegex()).filter { it.isNotBlank() }
    }
}

tokenizer("hello world") // ["hello", "world"]

tools() -- Agentic Skills

Calling tools() marks the skill as LLM-driven. The framework enters the agentic loop (see Architecture Overview) when this skill is selected.

Canonical form is typed. As of #1015–#1017, tool(...) returns a Tool<Args, Result> typed handle, and skill { tools(handle1, handle2) } is the canonical way to attach tools to a skill. The string-name overload tools("name", ...) is @Deprecated(level = WARNING) for user-declared tools — see Typed tool refs below.

agent<String, String>("coder") {
    lateinit var readFile: Tool<Map<String, Any?>, Any?>
    lateinit var writeFile: Tool<Map<String, Any?>, Any?>
    lateinit var compile: Tool<Map<String, Any?>, Any?>
    tools {
        readFile = tool("read_file", "Read a file") { args -> /* ... */ }
        writeFile = tool("write_file", "Write a file") { args -> /* ... */ }
        compile = tool("compile", "Compile sources") { args -> /* ... */ }
    }
    skills {
        skill<String, String>("implement", "Implement a feature from a description") {
            tools(readFile, writeFile, compile)
        }
    }
}

The vararg handles: Tool<*, *> parameter lists which of the agent's registered tools this skill may use. Pass no arguments to allow only knowledge tools and memory tools:

skill<String, String>("answer", "Answer a question using knowledge") {
    tools()  // agentic, but no action tools -- only knowledge tools are available
}

Properties after calling tools(...):

  • isAgentic is true
  • toolNames holds the list of tool name strings (possibly empty) — derived from the typed handles
  • implementation is null -- the LLM drives execution

Important: tools(...) and implementedBy { } are mutually exclusive. Calling tools(...) clears any previously set implementation, and vice versa.


Typed tool refs (#1015–#1017)

The canonical way to allowlist tools on a skill is to capture the handle returned by tool(...) and pass it positionally to tools(...):

// BEFORE — string form, soft-deprecated
agent<String, String>("coder") {
    tools {
        tool("write_file", "Write a file") { args -> /* ... */ }
        tool("compile", "Compile sources") { args -> /* ... */ }
    }
    skills {
        skill<String, String>("build") { tools("write_file", "compile") }
    }
}

// AFTER — typed form, canonical
agent<String, String>("coder") {
    lateinit var writeFile: Tool<Map<String, Any?>, Any?>
    lateinit var compile: Tool<Map<String, Any?>, Any?>
    tools {
        writeFile = tool("write_file", "Write a file") { args -> /* ... */ }
        compile = tool("compile", "Compile sources") { args -> /* ... */ }
    }
    skills {
        skill<String, String>("build") { tools(writeFile, compile) }
    }
}

What this buys you:

  • Compile-time typo safety. Misspell writeFile as writFile and IntelliJ paints a red squiggle before you ever start the JVM. The string form is validated at agent construction (#631), but only at runtime.
  • Refactor-friendly. Renaming a tool's variable propagates through every tools(...) reference; renaming a string requires search-and-replace.
  • Narrower types for typed tools. tool<Args, Result>(...) returns Tool<Args, Result>, not Tool<Map<String, Any?>, Any?> — the handle carries the schema with it.

The string form tools("name", ...) remains available and is @Deprecated(level = WARNING). No removal is planned pre-1.0. It stays the right call for two cases:

  1. Built-in tools like escalate, throwException, and memory_read / memory_write / memory_search — there's no user-declared tool(...) to capture a handle from.
  2. Runtime-discovered MCP tools — names like github.create_pull_request come from a remote MCP server at runtime, not a local tool(...) declaration.

Empty tools() (no args) is a separate, non-deprecated overload that simply marks the skill agentic with no action tools.

See also: Best Practices, Cookbook, Model & Tool Calling.


transformOutput

When the agent's OUT type is not String, the framework needs a way to parse the LLM's text response into a typed object. You have two options:

  1. @Generable annotation -- The framework auto-generates a lenient JSON deserializer. See Architecture Overview for details on the generation package.
  2. transformOutput { } -- Manual parsing lambda on the skill.
data class Sentiment(val label: String, val score: Double)

skill<String, Sentiment>("analyze", "Analyze sentiment") {
    tools()
    transformOutput { raw ->
        // raw is the LLM's text response
        val parts = raw.split(",")
        Sentiment(parts[0].trim(), parts[1].trim().toDouble())
    }
}

The transformOutput lambda takes the LLM's raw string response and returns OUT. It is called before the agent's castOut function, giving the skill full control over parsing.

If both transformOutput and @Generable are available, transformOutput takes priority.


Knowledge Entries

Knowledge entries attach named, lazily-evaluated data to a skill. They serve as the skill's context -- reference material, documentation, configuration, or any data the LLM might need.

agent<String, String>("coder") {
    lateinit var writeFile: Tool<Map<String, Any?>, Any?>
    tools {
        writeFile = tool("write_file", "Write a file") { args -> /* ... */ }
    }
    skills {
        skill<String, String>("implement", "Implement a feature") {
            knowledge("api-spec", "REST API specification") {
                File("docs/api-spec.yaml").readText()
            }
            knowledge("db-schema", "Database table definitions") {
                database.query("SELECT * FROM information_schema.tables").toString()
            }
            tools(writeFile)
        }
    }
}

Each knowledge() call takes three arguments:

Parameter Type Purpose
key String Unique name within the skill. Becomes the tool name in agentic mode.
description String Tells the LLM what this knowledge contains. Used in tool descriptions and toLlmDescription().
provider () -> String Lambda that produces the knowledge content. Evaluated lazily.

Lazy vs Eager Loading

Agentic skills: Knowledge entries are exposed as tools. The LLM calls them by name when it needs the data. The provider lambda executes only on demand. This keeps system prompts small and avoids loading unused knowledge.

System prompt includes:   "api-spec: REST API specification"  (description only)
LLM calls tool:          api-spec
Framework executes:       provider()  →  returns file contents

Non-agentic skills: When toLlmContext() is called (e.g., for prompt construction), all knowledge entries are evaluated eagerly and their content is included inline.

Lazy evaluation means fresh data

Because the provider is a lambda, it runs each time it is called. If the underlying data changes between calls, the LLM gets the latest version:

var callCount = 0
skill<Int, Int>("add", "Adds one") {
    knowledge("dynamic") { callCount++; "value $callCount" }
    implementedBy { it + 1 }
}
// First call:  knowledge["dynamic"]!!()  →  "value 1"
// Second call: knowledge["dynamic"]!!()  →  "value 2"

Loading prompts and knowledge from classpath resources

For long prompts or static knowledge bodies, keep the text in src/main/resources/... and load it with loadResource(path) instead of inline string literals. The path is fail-fast: a missing resource throws IllegalArgumentException at agent construction, not at first invocation.

import agents_engine.core.loadResource
import agents_engine.core.loadResourceOrNull

agent<String, String>("coder") {
    // System prompt — loaded once at construction, fails fast on typo.
    prompt(loadResource("prompts/coder.md"))

    skills {
        skill<String, String>("write", "Write Kotlin code") {
            // Static reference material — load eagerly inside the knowledge
            // lambda. The lambda still runs lazily on demand, so the I/O cost
            // is only paid when the LLM actually fetches the entry.
            knowledge("style-guide", "Project Kotlin style") {
                loadResource("prompts/style-guide.md")
            }
            // Optional knowledge — the agent works with or without it.
            knowledge("examples", "Few-shot examples") {
                loadResourceOrNull("prompts/examples.md") ?: ""
            }
            tools()
        }
    }
}

A leading slash on the path is tolerated (prompts/x.md and /prompts/x.md both work). The content is decoded as UTF-8. See Best Practices. (See: #980)


Shared Knowledge

Multiple agents or skills can share the same data source through closures. Because knowledge providers are lambdas, they can capture any external mutable state:

val corpus = mutableMapOf(
    "style" to "Prefer val over var. Use data classes.",
    "rules" to "Max line length 120. No wildcard imports.",
)

val coder = agent<String, String>("coder") {
    skills {
        skill<String, String>("write", "Write code") {
            knowledge("style-guide", "Coding style rules") { corpus["style"]!! }
            knowledge("rules", "Linting rules") { corpus["rules"]!! }
            implementedBy { "fun ${it}() {}" }
        }
    }
}

val reviewer = agent<String, String>("reviewer") {
    skills {
        skill<String, String>("review", "Review code") {
            knowledge("style-guide", "Coding style rules") { corpus["style"]!! }
            knowledge("rules", "Linting rules") { corpus["rules"]!! }
            implementedBy { "LGTM: $it" }
        }
    }
}

// Both agents see the same data. Update the corpus once, both see it:
corpus["style"] = "Prefer val over var. Use data classes. Use sealed interfaces."
// coder's knowledge now includes "sealed interfaces"
// reviewer's knowledge now includes "sealed interfaces"

This pattern is especially powerful in pipelines, where an earlier agent can mutate shared state and a later agent sees the updates via its lazy knowledge providers:

val context = mutableMapOf<String, String>()

val extractor = agent<String, String>("extractor") {
    skills {
        skill<String, String>("extract", "Extract keywords") {
            knowledge("context", "Shared pipeline context") { context.toString() }
            implementedBy { input ->
                val keywords = input.split(" ").filter { it.length > 3 }
                context["keywords"] = keywords.joinToString(",")
                keywords.joinToString(",")
            }
        }
    }
}

val formatter = agent<String, String>("formatter") {
    skills {
        skill<String, String>("format", "Format with context") {
            knowledge("context", "Shared pipeline context") { context.toString() }
            implementedBy { input ->
                val kw = context["keywords"] ?: "none"
                "Formatted [$kw]: $input"
            }
        }
    }
}

val pipeline = extractor then formatter
pipeline("The quick brown fox jumps")
// "Formatted [quick,brown,jumps]: quick,brown,jumps"

Auto-Generated LLM Descriptions

The framework generates prompt text from skill metadata. There are three levels of description:

toLlmDescription()

Returns a markdown description of the skill for use in routing prompts and system messages. Auto-generated from the skill's name, description, input/output types, and knowledge entry descriptions.

val s = skill<String, String>("summarize", "Condense text into a brief summary") {
    knowledge("style-guide", "Writing style rules") { "..." }
    tools()
}

println(s.toLlmDescription())

Output:

## Skill: summarize

**Input:** String
**Output:** String

Condense text into a brief summary

**Knowledge:**
- style-guide -- Writing style rules

You can override this entirely:

skill<String, String>("summarize", "Summarize") {
    llmDescription("You summarize text. Keep it under 3 sentences. Use active voice.")
    tools()
}

toLlmContext()

Returns the full description plus all knowledge content inlined. Used for non-agentic skills where knowledge cannot be loaded lazily via tools:

println(s.toLlmContext())

Output:

## Skill: summarize
...
Knowledge:
--- style-guide ---
Use short sentences. Prefer active voice.

knowledgeTools()

Returns a list of KnowledgeTool objects -- one per knowledge entry. The agentic loop converts these into ToolDef instances so the LLM can call them by name:

val tools: List<KnowledgeTool> = s.knowledgeTools()
// [KnowledgeTool(name="style-guide", description="Writing style rules", call=...)]

When the LLM calls the style-guide tool, the framework invokes call(), which runs the knowledge provider lambda. The result is returned to the LLM as a tool response message.


Summary

Feature Pure Kotlin Skill Agentic Skill
Defined with implementedBy { } tools(handle1, handle2, ...)
isAgentic false true
Execution Lambda called directly LLM agentic loop
Tools available None Named tools from agent's toolMap
Knowledge loading Eager (via toLlmContext()) Lazy (via tool calls)
transformOutput Not used Parses LLM text to OUT
LLM required No Yes (model { } must be configured)

Clone this wiki locally