DEV Community

Manuel Alejandro Hermoso
Manuel Alejandro Hermoso

Posted on AI-assisted

An AI agent that asks before it pays: human approval for tool calls in Kotlin

An agent that only talks is easy to trust. An agent with a refund tool is not. The model decides when to call it and with which amount, and the same question can get a different answer tomorrow.

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.

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 Telar, an open-source library for agent workflows that I am writing. Every example here was compiled and run against the current release, 0.1.0-alpha09.

1. The agent

A tool is a function the model may call. Its input is a @Serializable class, and the library builds the schema the model reads from that class.

@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) -> Unit): CompiledGraph<AgentState> {
    val orderStatus = Tool<OrderLookup>("order_status", "Returns what was ordered, what it cost and when it arrived.") { lookup ->
        "Order ${lookup.orderId}: one margherita for 9 euros, delivered 70 minutes late." // your database here
    }
    val refund = Tool<RefundRequest>("refund", "Pays money back for an order.") { request ->
        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.",
    )
}
Enter fullscreen mode Exit fullscreen mode

toolAgent 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.

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)
Enter fullscreen mode Exit fullscreen mode

This works, and it pays whenever the model thinks it should. That is the part to fix.

2. Pause before the tools run

The agent is not a black box. It is a graph of two nodes, model and tools, with an arrow back from one to the other. And a graph can be told to stop before a node:

val config = GraphConfig(
    threadId = "ticket-42",
    checkpointer = MemoryCheckpointer<AgentState>(),
    interruptBefore = setOf("tools"),
)
Enter fullscreen mode Exit fullscreen mode

Now invoke returns GraphResult.Interrupted 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:

suspend fun answer(
    agent: CompiledGraph<AgentState>,
    question: String,
    config: GraphConfig<AgentState>,
    approve: suspend (List<ToolCall>) -> 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 ->
                state.copy(messages = state.messages + refunds.map {
                    ChatMessage.ToolResult(it.id, it.name, "A person declined this refund.", isError = true)
                })
            }
        }
    }
    return result.state
}
Enter fullscreen mode Exit fullscreen mode

Three things happen in that loop:

  • Lookups pass without a question. The code decides which tools need a person, by name. Nobody is asked to approve reading an order.
  • A yes runs the call as the model wrote it. approve gets the calls, so your screen can show refund {"orderId":"1001","euros":9} before anyone clicks.
  • A no goes back to the model as a result. The model reads "A person declined this refund." and writes the customer an answer that fits. The conversation stays whole.

The difference from a prompt is where the guarantee lives. The refund function is not reached unless approve returned true. The model cannot talk its way past an if.

3. The pause survives a restart

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:

val config = GraphConfig(
    threadId = "ticket-42",
    // One JSON file per thread. AgentState is @Serializable, so nothing else is needed.
    checkpointer = FileCheckpointer(Path("checkpoints"), KotlinxStateSerializer<AgentState>()),
    interruptBefore = setOf("tools"),
)
Enter fullscreen mode Exit fullscreen mode

After a restart, lastResult reads where a thread stopped without running anything:

when (val result = agent.lastResult(config)) {
    is GraphResult.Interrupted -> showApproval(result.state.messages.pendingToolCalls()) // still waiting for a person
    is GraphResult.Completed -> showAnswer(result.state.answer)
    null -> showEmptyForm() // this ticket has never run
}
Enter fullscreen mode Exit fullscreen mode

agent.resume(config) then continues in the new process, from the step where the old one stopped.

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.

There is a file checkpointer and one for the browser's localStorage. There is no database module yet. Checkpointer is a small interface, and the guide shows one written for a database table.

4. Test it without a model

ChatModel 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:

val scripted = ChatModel { request ->
    val results = request.messages.filterIsInstance<ChatMessage.ToolResult>()
    ChatResponse(
        when (results.size) {
            0 -> ChatMessage.Assistant(toolCalls = listOf(ToolCall("call-1", "order_status", buildJsonObject { put("orderId", "1001") })))
            1 -> ChatMessage.Assistant(toolCalls = listOf(ToolCall("call-2", "refund", buildJsonObject { put("orderId", "1001"); put("euros", 9) })))
            else -> ChatMessage.Assistant(if (results.last().isError) "I am sorry, I cannot refund this order." else "Your 9 euros are on their way.")
        },
    )
}
Enter fullscreen mode Exit fullscreen mode

And the property that matters becomes a unit test that runs in milliseconds, with no key and no network:

@Test
fun aRefundThatAPersonDeclinesIsNotPaid() = runTest {
    val paid = mutableListOf<RefundRequest>()
    val agent = helpDesk(scripted, pay = { paid += it })
    val config = GraphConfig(checkpointer = MemoryCheckpointer<AgentState>(), 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)
}
Enter fullscreen mode Exit fullscreen mode

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.

5. Rules go in code, not in the prompt

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.

toolLoop is the same agent as a part of a graph of your own, with the conversation in a state you define. model, orderStatus and refund are the ones from step 1:

data class Complaint(
    val orderId: String,
    val text: String,
    val daysAgo: Int,
    val messages: List<ChatMessage> = emptyList(),
    val reply: String = "",
)

val desk = StateGraph<Complaint> {
    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 -> complaint.copy(messages = complaint.messages + new) },
        firstMessage = { "Order ${it.orderId}: ${it.text}" },
        then = send,
    )

    conditionalEdge(START, targets = setOf(agent, tooOld)) { if (it.daysAgo > 30) tooOld else agent }
    tooOld then send
    send then END
}.compile()
Enter fullscreen mode Exit fullscreen mode

An old order takes the arrow to too_old, and the model is not called at all. No tokens, no risk, and a test that proves it.

compile() 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.

Where this runs

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.

To see graphs like these run without writing code, there is a small game in the browser in which every stage is a Telar graph. It needs no account and no API key.

What is missing

Telar is an early alpha, and I would rather say so here than have you find out:

  • The API can still change before 1.0.
  • There is no MCP, no tracing, no retry policy for a node and no database checkpointer yet. They are on the roadmap.
  • If you need a complete agent platform today, Koog from JetBrains is the safer choice. The site has a comparison with Koog, LangGraph4j and LangChain4j.

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.

Try it

// 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
Enter fullscreen mode Exit fullscreen mode

It is early, which makes this the best time for feedback. If something in the API looks wrong to you, open an issue or tell me in the comments.

Top comments (0)