
Connect tools, compose logic, and manage agents with capabilities for chat, memory, streaming responses, and tool integration. Supports interaction with large language models.
The name "Koaks" is homophonic with "coax".
🧩 Connect your tools, compose your logic, rule your agents.
agent { } assembles an immutable, reusable agent.@Serializable class. Class-based tools, JVM @Tool annotations, and lazy MCP discovery are all supported.agent.run<T>() returns a typed, decoded result.Warning: The project is in a rapid iteration phase — the API may change at any time.
The current published group is org.koaks.framework. Pick the koaks-core runtime plus
the provider module(s) you need.
Gradle (Kotlin DSL)
// For Gradle projects — JVM or Kotlin Multiplatform — just add the artifact below.
// Gradle resolves the right platform variant automatically.
implementation("org.koaks.framework:koaks-core:0.0.1-snapshot1")
implementation("org.koaks.framework:koaks-model-qwen:0.0.1-snapshot1")
// Optional add-ons:
// implementation("org.koaks.framework:koaks-model-ollama:0.0.1-snapshot1")
// implementation("org.koaks.framework:koaks-memory-summarizing:0.0.1-snapshot1")
// implementation("org.koaks.framework:koaks-memory-vector:0.0.1-snapshot1")Maven
<!-- For Maven you must pick the platform variant yourself.
If you're unsure what that means, the JVM variant below is the one you want. -->
<dependency>
<groupId>org.koaks.framework</groupId>
<artifactId>koaks-core-jvm</artifactId>
<version>0.0.1-snapshot1</version>
</dependency>
<dependency>
<groupId>org.koaks.framework</groupId>
<artifactId>koaks-model-qwen-jvm</artifactId>
<version>0.0.1-snapshot1</version>
</dependency>import kotlinx.coroutines.runBlocking
import org.koaks.framework.loop.agent
import org.koaks.framework.loop.use
import org.koaks.provider.qwen.qwen
fun main() = runBlocking {
val agent = agent {
name = "assistant"
instructions = "You are a concise, helpful assistant."
model {
qwen(
baseUrl = "base-url",
apiKey = "api-key",
modelName = "qwen3-235b-a22b-instruct-2507",
)
}
}
agent.use {
val result = it.run("What's the meaning of life?")
println(result.text)
}
}run drives the agent to a terminal state and returns an AgentResult
(.text, .usage, .isSuccess). agent.use { } closes the transport the agent owns
when you're done.
Multi-segment & dynamic instructions. The instructions = "..." shorthand is fine for
a fixed prompt. When you need several pieces — or parts that depend on run-time context —
use the instructions { } block instead. Each dynamic { } segment is a suspend
provider resolved once per run (returning null/blank omits it); all non-blank
segments are joined with a blank line into the single system prompt.
agent {
instructions {
+"You are a concise, helpful assistant." // static
text("Always answer in English.") // static (explicit form)
dynamic { "Today is ${LocalDate.now()}." } // resolved per run
dynamic { lookupUserProfile(userId)?.let { "User prefs: $it" } } // null → skipped
}
model { qwen(baseUrl = "...", apiKey = "...", modelName = "qwen3-235b-a22b-instruct-2507") }
}Both forms coexist; if you set
instructions = "..."and aninstructions { }block on the same agent, the block wins. SeeDynamicInstructions.kt.KV-cache tip: keep the resolved instructions stable across the turns of a conversation. Changing them mid-conversation invalidates the provider's prompt cache, since the system prompt sits at the front of every request.
stream emits the loop's events as they happen — assistant text, the model's reasoning
trace, tool calls, and lifecycle markers.
import org.koaks.framework.loop.AgentEvent
agent.use {
it.stream("Explain Kotlin coroutines in two sentences.").collect { event ->
when (event) {
is AgentEvent.ReasoningDelta -> print(event.text) // model thinking
is AgentEvent.TextDelta -> print(event.text) // final answer
is AgentEvent.ToolCallRequested -> println("\n[tool] ${event.call.name}")
is AgentEvent.ToolResult -> println("[result] ${event.output}")
is AgentEvent.Completed -> println("\n[done]")
is AgentEvent.Terminated -> println("\n[terminated] ${event.reason}")
is AgentEvent.Failed -> println("\n[error] ${event.error.message}")
is AgentEvent.StepCompleted -> Unit
}
}
}Define a tool inline with a typed input — its JSON Schema is generated from the
@Serializable class, so the model knows exactly what arguments to send.
import kotlinx.serialization.Serializable
import org.koaks.framework.loop.agent
import org.koaks.framework.loop.tool
import org.koaks.framework.loop.use
import org.koaks.provider.qwen.qwen
@Serializable
data object NoInput
@Serializable
data class WeatherInput(val city: String)
fun main() = kotlinx.coroutines.runBlocking {
val agent = agent {
name = "weather-agent"
instructions = "Answer the user's questions, using tools when needed."
model {
qwen(baseUrl = "base-url", apiKey = "api-key", modelName = "qwen3-235b-a22b-instruct-2507") {
params { parallelToolCalls = true } // provider-level default
}
}
params { temperature = 0.3 } // agent-level params override provider defaults
tools {
tool<NoInput>(
name = "get_city",
description = "Get the city where the user is located",
) { "Shanghai" }
tool<WeatherInput>(
name = "get_weather",
description = "Get the weather for a specific city",
) { input -> "${input.city}: cloudy, with a high-wind warning." }
}
terminateAfter(maxSteps = 20)
}
agent.use {
println(it.run("What's the weather where I am?").text)
}
}You can also register class-based tools (Tool<In>), JVM @Tool annotated
functions, or connect an MCP server whose tools are discovered lazily:
tools {
tool(MyClassBasedTool()) // implements Tool<In>
mcp(myMcpGateway) // tools discovered on first run via tools/list
}Attach memory to the agent, then talk through a thread(id). History is loaded on each
turn and committed atomically only when the turn finishes — a failure or cancellation
leaves persisted history untouched.
val agent = agent {
model { qwen(baseUrl = "base-url", apiKey = "api-key", modelName = "qwen3-235b-a22b-instruct-2507") }
memory {
window(40) // sliding-window; or none() / custom(summarizingOrVectorMemory)
}
}
agent.use {
val chat = it.thread("user-1001")
println(chat.run("My name is Ada.").text)
println(chat.run("What's my name?").text) // remembers across turns
}Ask for a typed result and Koaks constrains the final step to valid JSON (native JSON mode when the model supports it, otherwise a schema-in-prompt fallback) and decodes it.
import org.koaks.framework.loop.run
@Serializable
data class CityWeather(val city: String, val tempC: Int)
agent.use {
val w: CityWeather = it.run<CityWeather>("What's the weather in Shanghai right now?")
println("${w.city}: ${w.tempC}°C")
}agent {
model {
// try Qwen first; fall back to Ollama only if the primary fails before any output
qwen(baseUrl = "...", apiKey = "...", modelName = "qwen3-235b-a22b-instruct-2507")
.fallback(ollama(baseUrl = "http://localhost:11434", modelName = "llama3.1"))
}
onError(org.koaks.framework.policy.ErrorPolicy.retryRetriable(maxRetries = 2))
runBudget(maxTotalSteps = 30, maxTotalTokens = 100_000) // whole-run global guard
}Typed hooks can transform model requests/streams and tool calls/results. Push-style
listeners (Tracing) remain observe-only:
agent {
hook {
onModelCall {
before { ctx -> ctx.request }
}
onToolCall {
before { ctx -> if (ctx.call.name == "danger") Deny("blocked") else Proceed }
}
}
install(org.koaks.framework.middleware.Tracing)
// install(Guardrail(...)); install(HumanApproval(...))
}| Module | Artifact | Purpose |
|---|---|---|
| core | koaks-core |
The agent runtime: DSL, loop, tools, memory, hooks/listeners, transport |
| qwen | koaks-model-qwen |
Qwen / OpenAI-compatible provider |
| ollama | koaks-model-ollama |
Local Ollama provider (NDJSON) |
| memory: summarizing | koaks-memory-summarizing |
Summarizing long-conversation memory |
| memory: vector | koaks-memory-vector |
Vector-store-backed memory |
| graph | koaks-graph |
Graph orchestration (in progress) |
Thank you for your interest in contributing! Code, documentation improvements, and issues are all welcome.
git checkout -b feature-xxx)git commit -m 'Add new feature')git push origin feature-xxx)Building from source requires JDK 21. Quick check:
./gradlew :tests:jvmTest.
This project makes use of, but is not limited to, the following open-source projects:
| Project | Description |
|---|---|
| Kotlin | The Kotlin Programming Language. |
| kotlin-logging | Lightweight multiplatform logging framework for Kotlin. A convenient and performant logging facade. |
The name "Koaks" is homophonic with "coax".
🧩 Connect your tools, compose your logic, rule your agents.
agent { } assembles an immutable, reusable agent.@Serializable class. Class-based tools, JVM @Tool annotations, and lazy MCP discovery are all supported.agent.run<T>() returns a typed, decoded result.Warning: The project is in a rapid iteration phase — the API may change at any time.
The current published group is org.koaks.framework. Pick the koaks-core runtime plus
the provider module(s) you need.
Gradle (Kotlin DSL)
// For Gradle projects — JVM or Kotlin Multiplatform — just add the artifact below.
// Gradle resolves the right platform variant automatically.
implementation("org.koaks.framework:koaks-core:0.0.1-snapshot1")
implementation("org.koaks.framework:koaks-model-qwen:0.0.1-snapshot1")
// Optional add-ons:
// implementation("org.koaks.framework:koaks-model-ollama:0.0.1-snapshot1")
// implementation("org.koaks.framework:koaks-memory-summarizing:0.0.1-snapshot1")
// implementation("org.koaks.framework:koaks-memory-vector:0.0.1-snapshot1")Maven
<!-- For Maven you must pick the platform variant yourself.
If you're unsure what that means, the JVM variant below is the one you want. -->
<dependency>
<groupId>org.koaks.framework</groupId>
<artifactId>koaks-core-jvm</artifactId>
<version>0.0.1-snapshot1</version>
</dependency>
<dependency>
<groupId>org.koaks.framework</groupId>
<artifactId>koaks-model-qwen-jvm</artifactId>
<version>0.0.1-snapshot1</version>
</dependency>import kotlinx.coroutines.runBlocking
import org.koaks.framework.loop.agent
import org.koaks.framework.loop.use
import org.koaks.provider.qwen.qwen
fun main() = runBlocking {
val agent = agent {
name = "assistant"
instructions = "You are a concise, helpful assistant."
model {
qwen(
baseUrl = "base-url",
apiKey = "api-key",
modelName = "qwen3-235b-a22b-instruct-2507",
)
}
}
agent.use {
val result = it.run("What's the meaning of life?")
println(result.text)
}
}run drives the agent to a terminal state and returns an AgentResult
(.text, .usage, .isSuccess). agent.use { } closes the transport the agent owns
when you're done.
Multi-segment & dynamic instructions. The instructions = "..." shorthand is fine for
a fixed prompt. When you need several pieces — or parts that depend on run-time context —
use the instructions { } block instead. Each dynamic { } segment is a suspend
provider resolved once per run (returning null/blank omits it); all non-blank
segments are joined with a blank line into the single system prompt.
agent {
instructions {
+"You are a concise, helpful assistant." // static
text("Always answer in English.") // static (explicit form)
dynamic { "Today is ${LocalDate.now()}." } // resolved per run
dynamic { lookupUserProfile(userId)?.let { "User prefs: $it" } } // null → skipped
}
model { qwen(baseUrl = "...", apiKey = "...", modelName = "qwen3-235b-a22b-instruct-2507") }
}Both forms coexist; if you set
instructions = "..."and aninstructions { }block on the same agent, the block wins. SeeDynamicInstructions.kt.KV-cache tip: keep the resolved instructions stable across the turns of a conversation. Changing them mid-conversation invalidates the provider's prompt cache, since the system prompt sits at the front of every request.
stream emits the loop's events as they happen — assistant text, the model's reasoning
trace, tool calls, and lifecycle markers.
import org.koaks.framework.loop.AgentEvent
agent.use {
it.stream("Explain Kotlin coroutines in two sentences.").collect { event ->
when (event) {
is AgentEvent.ReasoningDelta -> print(event.text) // model thinking
is AgentEvent.TextDelta -> print(event.text) // final answer
is AgentEvent.ToolCallRequested -> println("\n[tool] ${event.call.name}")
is AgentEvent.ToolResult -> println("[result] ${event.output}")
is AgentEvent.Completed -> println("\n[done]")
is AgentEvent.Terminated -> println("\n[terminated] ${event.reason}")
is AgentEvent.Failed -> println("\n[error] ${event.error.message}")
is AgentEvent.StepCompleted -> Unit
}
}
}Define a tool inline with a typed input — its JSON Schema is generated from the
@Serializable class, so the model knows exactly what arguments to send.
import kotlinx.serialization.Serializable
import org.koaks.framework.loop.agent
import org.koaks.framework.loop.tool
import org.koaks.framework.loop.use
import org.koaks.provider.qwen.qwen
@Serializable
data object NoInput
@Serializable
data class WeatherInput(val city: String)
fun main() = kotlinx.coroutines.runBlocking {
val agent = agent {
name = "weather-agent"
instructions = "Answer the user's questions, using tools when needed."
model {
qwen(baseUrl = "base-url", apiKey = "api-key", modelName = "qwen3-235b-a22b-instruct-2507") {
params { parallelToolCalls = true } // provider-level default
}
}
params { temperature = 0.3 } // agent-level params override provider defaults
tools {
tool<NoInput>(
name = "get_city",
description = "Get the city where the user is located",
) { "Shanghai" }
tool<WeatherInput>(
name = "get_weather",
description = "Get the weather for a specific city",
) { input -> "${input.city}: cloudy, with a high-wind warning." }
}
terminateAfter(maxSteps = 20)
}
agent.use {
println(it.run("What's the weather where I am?").text)
}
}You can also register class-based tools (Tool<In>), JVM @Tool annotated
functions, or connect an MCP server whose tools are discovered lazily:
tools {
tool(MyClassBasedTool()) // implements Tool<In>
mcp(myMcpGateway) // tools discovered on first run via tools/list
}Attach memory to the agent, then talk through a thread(id). History is loaded on each
turn and committed atomically only when the turn finishes — a failure or cancellation
leaves persisted history untouched.
val agent = agent {
model { qwen(baseUrl = "base-url", apiKey = "api-key", modelName = "qwen3-235b-a22b-instruct-2507") }
memory {
window(40) // sliding-window; or none() / custom(summarizingOrVectorMemory)
}
}
agent.use {
val chat = it.thread("user-1001")
println(chat.run("My name is Ada.").text)
println(chat.run("What's my name?").text) // remembers across turns
}Ask for a typed result and Koaks constrains the final step to valid JSON (native JSON mode when the model supports it, otherwise a schema-in-prompt fallback) and decodes it.
import org.koaks.framework.loop.run
@Serializable
data class CityWeather(val city: String, val tempC: Int)
agent.use {
val w: CityWeather = it.run<CityWeather>("What's the weather in Shanghai right now?")
println("${w.city}: ${w.tempC}°C")
}agent {
model {
// try Qwen first; fall back to Ollama only if the primary fails before any output
qwen(baseUrl = "...", apiKey = "...", modelName = "qwen3-235b-a22b-instruct-2507")
.fallback(ollama(baseUrl = "http://localhost:11434", modelName = "llama3.1"))
}
onError(org.koaks.framework.policy.ErrorPolicy.retryRetriable(maxRetries = 2))
runBudget(maxTotalSteps = 30, maxTotalTokens = 100_000) // whole-run global guard
}Typed hooks can transform model requests/streams and tool calls/results. Push-style
listeners (Tracing) remain observe-only:
agent {
hook {
onModelCall {
before { ctx -> ctx.request }
}
onToolCall {
before { ctx -> if (ctx.call.name == "danger") Deny("blocked") else Proceed }
}
}
install(org.koaks.framework.middleware.Tracing)
// install(Guardrail(...)); install(HumanApproval(...))
}| Module | Artifact | Purpose |
|---|---|---|
| core | koaks-core |
The agent runtime: DSL, loop, tools, memory, hooks/listeners, transport |
| qwen | koaks-model-qwen |
Qwen / OpenAI-compatible provider |
| ollama | koaks-model-ollama |
Local Ollama provider (NDJSON) |
| memory: summarizing | koaks-memory-summarizing |
Summarizing long-conversation memory |
| memory: vector | koaks-memory-vector |
Vector-store-backed memory |
| graph | koaks-graph |
Graph orchestration (in progress) |
Thank you for your interest in contributing! Code, documentation improvements, and issues are all welcome.
git checkout -b feature-xxx)git commit -m 'Add new feature')git push origin feature-xxx)Building from source requires JDK 21. Quick check:
./gradlew :tests:jvmTest.
This project makes use of, but is not limited to, the following open-source projects:
| Project | Description |
|---|---|
| Kotlin | The Kotlin Programming Language. |
| kotlin-logging | Lightweight multiplatform logging framework for Kotlin. A convenient and performant logging facade. |