<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Telar]]></title><description><![CDATA[Telar]]></description><link>https://deeptelar.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ace8dc1e1ef527d57b7b255/32c81a28-2134-49b9-bdfe-8708e3cab696.png</url><title>Telar</title><link>https://deeptelar.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 22 Oct 2026 04:41:43 GMT</lastBuildDate><atom:link href="https://deeptelar.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[An AI agent that asks before it pays: human approval for tool calls in Kotlin

]]></title><description><![CDATA[Build a Kotlin AI agent that pauses before a tool call and waits for a person to approve it. The pause survives a restart, and the tests need no model]]></description><link>https://deeptelar.hashnode.dev/an-ai-agent-that-asks-before-it-pays-human-approval-for-tool-calls-in-kotlin</link><guid isPermaLink="true">https://deeptelar.hashnode.dev/an-ai-agent-that-asks-before-it-pays-human-approval-for-tool-calls-in-kotlin</guid><category><![CDATA[Kotlin]]></category><category><![CDATA[Kotlin Multiplatform]]></category><category><![CDATA[agentic workflow]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[kotlin coroutines]]></category><category><![CDATA[agentic AI]]></category><dc:creator><![CDATA[Manuel Alejandro Hermoso]]></dc:creator><pubDate>Tue, 13 Oct 2026 20:15:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace8dc1e1ef527d57b7b255/bda3573c-ee58-43b5-8021-ccf02bf16e18.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>An agent that only talks is easy to trust. An agent with a <code>refund</code> tool is not. The model decides when to call it and with which amount, and the same question can get a different answer tomorrow.</p>
<p>I don't think a better prompt solves that. "Never refund without permission" is a sentence the model usually follows, and "usually" is not good enough for money. What solves it is structure around the model: ordinary code that decides where the model may act alone and where it has to wait.</p>
<p>This article builds that in Kotlin, in five steps: an agent, a pause before it acts, a pause that survives a restart, a test without a model, and a rule the model never gets to see. It uses <a href="https://github.com/deeptelar/telar">Telar</a>, an open-source library for agent workflows that I am writing. Every example here was compiled and run against the current release, <code>0.1.0-alpha09</code>.</p>
<h2>1. The agent</h2>
<p>A tool is a function the model may call. Its input is a <code>@Serializable</code> class, and the library builds the schema the model reads from that class.</p>
<pre><code class="language-kotlin">@Serializable
data class OrderLookup(
    @Description("The order number, for example \"1001\"") val orderId: String,
)

@Serializable
data class RefundRequest(
    @Description("The order number, for example \"1001\"") val orderId: String,
    @Description("The amount to pay back, in euros") val euros: Int,
)

fun helpDesk(model: ChatModel, pay: suspend (RefundRequest) -&gt; Unit): CompiledGraph&lt;AgentState&gt; {
    val orderStatus = Tool&lt;OrderLookup&gt;("order_status", "Returns what was ordered, what it cost and when it arrived.") { lookup -&gt;
        "Order ${lookup.orderId}: one margherita for 9 euros, delivered 70 minutes late." // your database here
    }
    val refund = Tool&lt;RefundRequest&gt;("refund", "Pays money back for an order.") { request -&gt;
        pay(request)
        "Paid back ${request.euros} euros for order ${request.orderId}."
    }
    return toolAgent(
        model,
        tools = listOf(orderStatus, refund),
        system = "You work at the help desk of a pizzeria. Look an order up before you decide anything about it.",
    )
}
</code></pre>
<p><code>toolAgent</code> is the usual loop: the model answers or asks for tools, the tools run, and their results go back to the model, until it has an answer.</p>
<pre><code class="language-kotlin">val model = AnthropicChatModel(HttpClient(), apiKey = System.getenv("ANTHROPIC_API_KEY"), model = "claude-opus-5-5")
val agent = helpDesk(model, pay = { payments.refund(it.orderId, it.euros) })

val state = agent.invoke(AgentState("My pizza for order 1001 came 70 minutes late. I want my money back.")).state
println(state.answer)
</code></pre>
<p>This works, and it pays whenever the model thinks it should. That is the part to fix.</p>
<h2>2. Pause before the tools run</h2>
<p>The agent is not a black box. It is a graph of two nodes, <code>model</code> and <code>tools</code>, with an arrow back from one to the other. And a graph can be told to stop before a node:</p>
<pre><code class="language-kotlin">val config = GraphConfig(
    threadId = "ticket-42",
    checkpointer = MemoryCheckpointer&lt;AgentState&gt;(),
    interruptBefore = setOf("tools"),
)
</code></pre>
<p>Now <code>invoke</code> returns <code>GraphResult.Interrupted</code> each time the model has asked for tools and none of them has run yet. The calls that wait are in the state, with their names and their inputs:</p>
<pre><code class="language-kotlin">suspend fun answer(
    agent: CompiledGraph&lt;AgentState&gt;,
    question: String,
    config: GraphConfig&lt;AgentState&gt;,
    approve: suspend (List&lt;ToolCall&gt;) -&gt; Boolean,
): AgentState {
    var result = agent.invoke(AgentState(question), config)
    while (result is GraphResult.Interrupted) {
        val refunds = result.state.messages.pendingToolCalls().filter { it.name == "refund" }
        result = if (refunds.isEmpty() || approve(refunds)) {
            // Nothing risky, or a person said yes: run the calls.
            agent.resume(config)
        } else {
            // A person said no: answer the call yourself. A call that already has a result is not run.
            agent.resume(config) { state -&gt;
                state.copy(messages = state.messages + refunds.map {
                    ChatMessage.ToolResult(it.id, it.name, "A person declined this refund.", isError = true)
                })
            }
        }
    }
    return result.state
}
</code></pre>
<p>Three things happen in that loop:</p>
<ul>
<li><p><strong>Lookups pass without a question.</strong> The code decides which tools need a person, by name. Nobody is asked to approve reading an order.</p>
</li>
<li><p><strong>A yes runs the call as the model wrote it.</strong> <code>approve</code> gets the calls, so your screen can show <code>refund {"orderId":"1001","euros":9}</code> before anyone clicks.</p>
</li>
<li><p><strong>A no goes back to the model as a result.</strong> The model reads "A person declined this refund." and writes the customer an answer that fits. The conversation stays whole.</p>
</li>
</ul>
<p>The difference from a prompt is where the guarantee lives. The <code>refund</code> function is not reached unless <code>approve</code> returned true. The model cannot talk its way past an <code>if</code>.</p>
<h2>3. The pause survives a restart</h2>
<p>A person may answer in a minute or after lunch, and the server may be deployed in between. Telar saves a checkpoint after every step, so the only change is where it is saved:</p>
<pre><code class="language-kotlin">val config = GraphConfig(
    threadId = "ticket-42",
    // One JSON file per thread. AgentState is @Serializable, so nothing else is needed.
    checkpointer = FileCheckpointer(Path("checkpoints"), KotlinxStateSerializer&lt;AgentState&gt;()),
    interruptBefore = setOf("tools"),
)
</code></pre>
<p>After a restart, <code>lastResult</code> reads where a thread stopped without running anything:</p>
<pre><code class="language-kotlin">when (val result = agent.lastResult(config)) {
    is GraphResult.Interrupted -&gt; showApproval(result.state.messages.pendingToolCalls()) // still waiting for a person
    is GraphResult.Completed -&gt; showAnswer(result.state.answer)
    null -&gt; showEmptyForm() // this ticket has never run
}
</code></pre>
<p><code>agent.resume(config)</code> then continues in the new process, from the step where the old one stopped.</p>
<p>One thing to know before you rely on this for payments: a step that fails is not saved, so resuming it runs its tools again. Make a side effect like a refund safe to repeat, for example with an idempotency key made from the order number.</p>
<p>There is a file checkpointer and one for the browser's <code>localStorage</code>. There is no database module yet. <code>Checkpointer</code> is a small interface, and the guide shows one written for a database table.</p>
<h2>4. Test it without a model</h2>
<p><code>ChatModel</code> is an interface with one function, so a model that follows a script is a lambda. This one looks the order up, asks for the refund, and then answers according to what came back:</p>
<pre><code class="language-kotlin">val scripted = ChatModel { request -&gt;
    val results = request.messages.filterIsInstance&lt;ChatMessage.ToolResult&gt;()
    ChatResponse(
        when (results.size) {
            0 -&gt; ChatMessage.Assistant(toolCalls = listOf(ToolCall("call-1", "order_status", buildJsonObject { put("orderId", "1001") })))
            1 -&gt; ChatMessage.Assistant(toolCalls = listOf(ToolCall("call-2", "refund", buildJsonObject { put("orderId", "1001"); put("euros", 9) })))
            else -&gt; ChatMessage.Assistant(if (results.last().isError) "I am sorry, I cannot refund this order." else "Your 9 euros are on their way.")
        },
    )
}
</code></pre>
<p>And the property that matters becomes a unit test that runs in milliseconds, with no key and no network:</p>
<pre><code class="language-kotlin">@Test
fun aRefundThatAPersonDeclinesIsNotPaid() = runTest {
    val paid = mutableListOf&lt;RefundRequest&gt;()
    val agent = helpDesk(scripted, pay = { paid += it })
    val config = GraphConfig(checkpointer = MemoryCheckpointer&lt;AgentState&gt;(), interruptBefore = setOf("tools"))

    val state = answer(agent, "My pizza for order 1001 came 70 minutes late. I want my money back.", config, approve = { false })

    assertEquals(emptyList(), paid)
    assertEquals("I am sorry, I cannot refund this order.", state.answer)
}
</code></pre>
<p>The model is the part you cannot predict. Everything around it is plain Kotlin on an immutable state, and you test it the way you test any other Kotlin.</p>
<h2>5. Rules go in code, not in the prompt</h2>
<p>Some rules have no exceptions. Say orders older than 30 days are never refunded. You could write that into the system prompt and hope. Or the model never sees those orders.</p>
<p><code>toolLoop</code> is the same agent as a part of a graph of your own, with the conversation in a state you define. <code>model</code>, <code>orderStatus</code> and <code>refund</code> are the ones from step 1:</p>
<pre><code class="language-kotlin">data class Complaint(
    val orderId: String,
    val text: String,
    val daysAgo: Int,
    val messages: List&lt;ChatMessage&gt; = emptyList(),
    val reply: String = "",
)

val desk = StateGraph&lt;Complaint&gt; {
    val tooOld = node("too_old") { it.copy(reply = "Orders older than 30 days cannot be refunded.") }
    val send = node("send") { it.copy(reply = it.reply.ifEmpty { it.messages.last().text }) }
    val agent = toolLoop(
        model = model,
        tools = listOf(orderStatus, refund),
        // Where the conversation is in your state, and how to add messages to it.
        messages = { it.messages },
        append = { complaint, new -&gt; complaint.copy(messages = complaint.messages + new) },
        firstMessage = { "Order ${it.orderId}: ${it.text}" },
        then = send,
    )

    conditionalEdge(START, targets = setOf(agent, tooOld)) { if (it.daysAgo &gt; 30) tooOld else agent }
    tooOld then send
    send then END
}.compile()
</code></pre>
<p>An old order takes the arrow to <code>too_old</code>, and the model is not called at all. No tokens, no risk, and a test that proves it.</p>
<p><code>compile()</code> checks the drawing before anything runs. A node that nothing leads to, or an arrow to a node that does not exist, fails there and not in production at the fourth step of a run.</p>
<h2>Where this runs</h2>
<p>All of the above is Kotlin Multiplatform. The core depends only on kotlinx-coroutines, and the same graph runs on the JVM, Android, iOS, desktop, and in the browser as JS or Wasm. The models are Claude and anything with the API of OpenAI (OpenAI, Gemini, Ollama, Groq and others) on Ktor client, and any LangChain4j model on the JVM.</p>
<p>To see graphs like these run without writing code, there is <a href="https://deeptelar.github.io/telar-demo/">a small game in the browser</a> in which every stage is a Telar graph. It needs no account and no API key.</p>
<h2>What is missing</h2>
<p>Telar is an early alpha, and I would rather say so here than have you find out:</p>
<ul>
<li><p>The API can still change before 1.0.</p>
</li>
<li><p>There is no MCP, no tracing, no retry policy for a node and no database checkpointer yet. They are on the <a href="https://github.com/deeptelar/telar/blob/main/ROADMAP.md">roadmap</a>.</p>
</li>
<li><p>If you need a complete agent platform today, <a href="https://github.com/JetBrains/koog">Koog</a> from JetBrains is the safer choice. The site has <a href="https://deeptelar.github.io/telar/why-telar">a comparison</a> with Koog, LangGraph4j and LangChain4j.</p>
</li>
</ul>
<p>Telar is for the case this article is about: the workflow around the model is the hard part, and you want it to be ordinary, typed, testable Kotlin.</p>
<h2>Try it</h2>
<pre><code class="language-kotlin">// build.gradle.kts. `@Serializable` needs the plugin kotlin("plugin.serialization").
implementation("dev.deeptelar:telar-agent:0.1.0-alpha09")
implementation("dev.deeptelar:telar-anthropic:0.1.0-alpha09")       // or telar-openai: OpenAI, Gemini, Ollama, ...
implementation("dev.deeptelar:telar-checkpoint-file:0.1.0-alpha09") // step 3
implementation("dev.deeptelar:telar-serialization:0.1.0-alpha09")   // step 3
implementation("io.ktor:ktor-client-cio:3.6.0")                     // any Ktor engine
</code></pre>
<ul>
<li><p><strong>Code:</strong> <a href="https://github.com/deeptelar/telar">https://github.com/deeptelar/telar</a></p>
</li>
<li><p><strong>Documentation and tutorial:</strong> <a href="https://deeptelar.github.io/telar/">https://deeptelar.github.io/telar/</a></p>
</li>
<li><p><strong>The guides behind this article:</strong> <a href="https://deeptelar.github.io/telar/guides/agents-with-tools">Agents with tools</a> and <a href="https://deeptelar.github.io/telar/guides/human-in-the-loop">Human-in-the-loop</a></p>
</li>
</ul>
<p>It is early, which makes this the best time for feedback. If something in the API looks wrong to you, <a href="https://github.com/deeptelar/telar/issues">open an issue</a> or tell me in the comments.</p>
<p>Kotlin developer. I build Telar, an open-source Kotlin Multiplatform library for AI agent workflows. Here to talk about Kotlin, KMP and agents, and to hear what breaks.</p>
<p>Stars follow users, and contributors follow stars, so the first few will come from individual conversations like the Reddit one.</p>
]]></content:encoded></item></channel></rss>