Koog 1.2 idioms, gotchas, and scaffolding skills for Kotlin agents on the JVM
71
89%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
Process steps in order. Do not skip ahead.
CliAIAgent shells out to a coding-agent CLI and parses its output. Reach for it
when you want a step to run on a vendor you have a subscription to rather than an
API key, or when you deliberately want a second vendor's judgement in the loop.
Do not reach for it as a general LLM call. It is one to two orders of magnitude slower than an API call — budget 10-60s per invocation against 2-3s for a direct API call.
If the request is an ordinary model call — one prompt, one completion, no second
vendor's judgement wanted — recommend a PromptExecutor and say why it is the better
fit. Finish here. Do not add agents-cli.
Continue only when the user wants a subscription-authenticated CLI or a deliberate second-vendor step. Ask which CLI, and confirm it is installed and logged in before writing code.
Proceed to Step 1.
Path: build.gradle.kts
implementation("ai.koog:agents-cli:1.2.0-beta")Beta version line, not 1.2.0.
Proceed immediately to Step 2.
Path: Critic.kt (or wherever the step lives)
import ai.koog.agents.cli.CliAIAgent
import ai.koog.agents.cli.transport.CliTransport
import ai.koog.agents.cli.claude.ClaudePermissionMode
val reviewer = CliAIAgent.claude(
transport = CliTransport.default(),
outputClass = Critique::class, // @Serializable — gives typed output
apiKey = null, // null = use the CLI's own auth. This is the point.
permissionMode = ClaudePermissionMode.BypassPermissions,
workspace = scratchDir, // see Step 3
systemPrompt = "...",
generateRequest = { input -> "...$input" }, // MUST be named, see below
)Four things that will each cost you a build:
apiKey = null is deliberate. Passing a key sets ANTHROPIC_API_KEY /
CODEX_API_KEY in the child environment and overrides the subscription. Leave it
null to use the logingenerateRequest must be a named argument. As a trailing lambda it binds to
installFeatures instead, and the error is a confusing arity mismatch on
FeatureContextCliTransport.default() is a function call, not a propertycodex additionally needs additionalFlags = listOf("--skip-git-repo-check")
unless the workspace is a trusted git repositoryConstructors: CliAIAgent.claude(...), CliAIAgent.codex(...), and
CliAIAgent.builder(transport) for any other binary — the custom builder needs
binaryPath, flags, generateRequest and extractOutput.
Proceed immediately to Step 3.
workspace defaults to ".". These are coding agents: they will read the working
directory — including files the step was never meant to depend on. That makes a
CLI-backed stage silently dependent on whatever happens to be nearby, and an
exfiltration path when the prompt is attacker-influenced.
private val scratchDir = Files.createTempDirectory("cli-agent")
.toFile().apply { deleteOnExit() }.absolutePathGive every CLI-backed step an empty scratch workspace unless it genuinely needs project files.
Proceed immediately to Step 4.
.asNode() turns the agent into a graph node. With outputClass, the node's output
type is CliAgentStructuredResponse<T>, and structuredResult is nullable.
Path: Strategy.kt
import ai.koog.agents.cli.asNode // required import — asNode is an extension
val review by reviewer.asNode("review")
edge(draft forwardTo review)
edge(review forwardTo nodeFinish
onCondition { it.structuredResult?.approved == true }
transformed { lastDraft!! })
edge(review forwardTo fix
onCondition { it.structuredResult?.approved != true }
transformed { it.structuredResult?.feedback ?: "reviewer returned nothing parseable" })Fail closed. A CLI agent that times out, crashes, or emits unparseable output
yields a null structuredResult. Treat null as rejection, never as approval —
?.approved == true, never != false.
A structured response carries only what you declared. If the next node needs the thing
being reviewed, capture it on the inbound edge (transformed { last = it; it }); the
critique will not carry it for you.
The review → fix → review cycle above is unbounded as written. Bound it with a
run-scoped refusal counter and terminate on an explicit rejection — use
Skill(skill: "author-strategy") for that shape.
Proceed immediately to Step 5.
claude -p "say PONG",
codex exec --skip-git-repo-check "say PONG". Fix auth here, not in KotlinstructuredResult is non-null on a normal runtimeout accordinglySkill(skill: "author-strategy")PromptExecutor instead.tessl-plugin
skills
add-observability
add-persistence
add-rag
add-structured-output
add-token-budgeting
add-tool
cache-llm-calls
define-prompt
domain-model-subtask-pipeline
references
enable-prompt-caching
handle-agent-events
manage-state
migrate-from-0-x
model-planner-subtasks
persist-chat-history
query-sql-from-agent
scaffold-agent
snapshot-and-restore
test-koog-agents
trace-agent-internals
use-agent-skills
use-attachments
use-cli-agents
use-functional-agent
use-llm-node-variants
use-planner
wire-a2a
wire-acp-server
wire-ktor-server
wire-mcp-server
wire-spring-boot