Building SoPhi · Article 06

Hands that Don’t Freeze: A Tool Interface Built For a Future it Hadn’t Earned Yet

Shukri Shukriev · · Also on Medium

Series: Building SoPhi — a Kotlin Agent Harness — Article 06

Illustration for Hands that Don’t Freeze: A Tool Interface Built For a Future it Hadn’t Earned Yet

A line cook working a busy pass doesn’t finish one dish before starting the next. They drop pasta in the water, turn to sear a steak while it cooks, plate a salad while the steak rests, and never once stand frozen at a single burner waiting for something to finish before touching anything else. What makes that possible isn’t superhuman speed — it’s that “start something and come back to it” is built into how the kitchen works, down to the tickets on the rail and the timers on the line. A kitchen designed around “one dish at a time, start to finish” would fall over the moment a table ordered more than one course.

ADR-006 is dated June 28th — one day after ADR-005 gave the agent a memory. Now it needed hands. The decision it makes looks almost too small to deserve a document: should Tool.execute() be a suspend function, and should it return a raw String? Both halves of that question turned out to be load-bearing for everything the agent does after Phase 1, including things that didn't exist yet when the ADR was written.

The interface

Kotlin
interface Tool {
  val name: String
  val description: String
  val parametersJson: String

  suspend fun execute(argumentsJson: String): String
}

Every tool call is I/O: a file read, an HTTP fetch, a subprocess, a delegated subagent turn. If execute() were a blocking function, the agent loop would have exactly one option when the model asks for three tool calls in the same turn: run them one after another, thread blocked the whole time — a kitchen that finishes the pasta before it's even allowed to think about the steak.

A hole shaped like the future

suspend isn't a style choice here — it's what makes this legal a month later, once the loop needs to run more than one tool call per round:

Kotlin
val toolOutcomes = coroutineScope {
  allowedCalls.map { (call, allowed) -> async {
    onEvent(TurnEvent.ToolCallStarted(call.name, call.argumentsJson))
    val result = if (!allowed) {
      "Error: Tool '${call.name}' execution denied by confirmation policy"
    } else {
      registry.getOrNull(call.name)?.let { tool -> runCatching {
        tool.execute(call.argumentsJson)
      }.getOrElse { e -> "Error: ${e.message}" } }
      ?: "Error: Tool '${call.name}' not found"
    }
    ...
  }
}.awaitAll()}

async inside coroutineScope, awaitAll() at the end — that's the entire mechanism for running tool calls in true parallel. It's the tickets-on-the-rail model: fire everything that's ready, work each one as it needs attention, collect the finished plates together at the end. It works because execute() was declared suspend on day one, in a codebase that at the time had exactly one tool and no parallelism to speak of. Nobody had to touch the Tool interface to add concurrency later, because the interface already had a hole in it shaped exactly like "can be worked alongside other things."

No wrapper, no ceremony

The second half of the decision — plain String in, plain String out, no typed result wrapper — is a bet on legibility over correctness-by-construction. The LLM already sent arguments as a JSON string; returning a JSON string means a tool result goes straight back to the model with no re-serialization step in between. And errors don't get their own type at all. They're a string, by convention, spelled "Error: ":

Kotlin
registry.getOrNull(call.name)?.let { tool ->
  runCatching {
    tool.execute(call.argumentsJson)
  }.getOrElse { e -> failed = true; "Error: ${e.message}" }
} ?: run { failed = true; "Error: Tool '${call.name}' not found" }

A Result wrapper was considered and rejected in the ADR itself — "adds complexity with no phase 1 benefit" — and several weeks of tool additions later, that's held up. Every tool author writes one function that returns one string, the same way every station on the line hands back one finished plate, not a form explaining how the dish was cooked. There's no serialization layer to maintain per tool, no typed-error hierarchy to keep in sync with whatever the LLM provider expects.

The one field it grew

The interface did grow one member it didn’t have on day one — riskLevel, added when the confirmation-policy system arrived — but it arrived as a field with a default, not a breaking change:

Kotlin
interface Tool {
  val name: String
  val description: String
  val parametersJson: String
  val riskLevel: RiskLevel get() = RiskLevel.SAFE

  suspend fun execute(argumentsJson: String): String
}

Every tool written against the original four-member interface kept compiling, because riskLevel defaults to SAFE unless a tool explicitly says otherwise. The interface that shipped in ADR-006 turned out to have exactly enough give in it to gain a whole new safety subsystem without anyone going back to touch the tools that already existed. That subsystem is worth its own section, because it's the part of this story where "hands that don't freeze" quietly needs a manager who has to sign the ticket before certain dishes leave the kitchen — and, a month later, needs to teach that manager which tickets don't even need a look.

Why a tool call needs a manager’s sign-off

Here’s the thing nobody worries about on day one, because on day one there’s exactly one tool and a human typing every prompt: an LLM’s decision to call a tool is not code review. Nobody reads the arguments before they run. That’s fine when the tool is read_file — worst case, the model reads the wrong file. It stops being fine the moment a tool can write a file, run a shell command, or fetch an arbitrary URL, and it stops being survivable the moment nobody's watching at all — which, by the time sophi-schedule existed, was a real mode of operation, not a hypothetical. A scheduled task fires at 3 am. There's no terminal to print "Allow? [y/N]" into, and no human awake to answer it — the kitchen equivalent of the line running unsupervised overnight, with whatever's on the ticket going straight to a customer no manager ever saw.riskLevel started as the tool's own fixed declaration of which category it's in — SAFE by default, DESTRUCTIVE opt-in for anything that changes state or reaches the network. It held for about a month. Then bash happened: a single command tool that runs anything from git status to rm -rf, and a constant per-tool tier can't tell those apart without either nagging on every read-only call or waving every destructive one through. ADR-016 turned the declaration into a decision:

Kotlin
enum class RiskLevel { SAFE, CAUTION, DESTRUCTIVE }
interface Tool {
  val name: String
  val description: String
  val parametersJson: String

  fun riskLevel(argumentsJson: String): RiskLevel = RiskLevel.SAFE
  suspend fun execute(argumentsJson: String): String
}

riskLevel is a function now, not a field — a tool gets to look at the arguments it was actually called with before answering. Most tools still ignore the argument and return a constant, exactly as before. bash doesn't:

Kotlin
override fun riskLevel(argumentsJson: String): RiskLevel {
  val command = runCatching {
    json.decodeFromString<BashArgs>(argumentsJson).command
  }.getOrNull() ?: return RiskLevel.DESTRUCTIVE

  val looksReadOnly = READ_ONLY_PREFIXES.any {
    command.trim().startsWith(it)
  } && command.none { it in UNSAFE_SHELL_CHARS }

  return if (looksReadOnly) RiskLevel.CAUTION else RiskLevel.DESTRUCTIVE
}

git status reads as CAUTION — runs unprompted in an attended session, still needs a grant or a yes in an unattended one. rm -rf reads as DESTRUCTIVE — always needs one. A declaration by itself still doesn't stop anything, though; it just labels the ticket. The gate lives one layer up, in the agent loop, behind an interface that changed shape right alongside riskLevel — because gating five parallel tool calls one confirmation at
a time was leaving the same performance on the table that a blocking execute() would have:

Kotlin
data class ConfirmationRequest(
  val callId: String,
  val toolName: String,
  val argumentsJson: String,
  val riskLevel: RiskLevel,
  val preview: String? = null
)

fun interface ConfirmationPolicy {
  suspend fun confirm(requests: List<ConfirmationRequest>): Map<String, Boolean>

  companion object {
    val ALLOW_ALL: ConfirmationPolicy = ConfirmationPolicy { it.associate { r -> r.callId to true } }
    val DENY_ALL: ConfirmationPolicy = ConfirmationPolicy { it.associate { r -> r.callId to false } }
  }
}

One round-trip per tool-calling round, not one per non-SAFE call — a whole round's worth of tickets handed to the manager together, the same batch that's about to run concurrently anyway:

Gating five parallel tool calls one confirmation at a time was an artifact of the original interface shape, not a deliberate choice — ADR-016 made the gate match the execution it guards.
Gating five parallel tool calls one confirmation at a time was an artifact of the original interface shape, not a deliberate choice — ADR-016 made the gate match the execution it guards.
Kotlin
val needsDecision = classified.filter { (_, req) ->
  req.riskLevel != RiskLevel.SAFE && req.toolName !in <grants>
}

val decisions = if (needsDecision.isEmpty()) emptyMap()
  else confirmationPolicy.confirm(needsDecision.map { it.second })

val allowedCalls = classified.map { (call, req) ->
  val allowed = req.riskLevel == RiskLevel.SAFE ||
    req.toolName in <grants>|| decisions[req.callId] == true
    <call to allowed>
}

Notice what’s still not in that line: a SAFE tool never touches the policy, and now neither does a tool the loop was handed a grants name for. That's the design, unchanged from ADR-006 — asking a human to bless every single grep call would turn five parallel tool calls into five rounds of friction. Nobody stops the line to approve the salad.

What makes this worth a whole section is still that ConfirmationPolicy doesn't prescribe who answers "yes." Two real answers exist in this codebase today — a human, or nobody:

Kotlin
// A human, asked live, in the terminal — one prompt for the whole round, not one per call:
class TerminalConfirmationPolicy(
  private val terminal: Terminal,
  private val input: InputSource) : ConfirmationPolicy {
    override suspend fun confirm(requests: List<ConfirmationRequest>): Map<String, Boolean> {
      if (requests.size == 1) {
        val r = requests.single()

        terminal.println(TextColors.yellow("Sophi wants to run '${r.toolName}' (${r.riskLevel}): ${r.preview ?: r.argumentsJson}"))
        terminal.print(TextColors.yellow("Allow? [y/N] "))

        return mapOf(r.callId to input.awaitYesNo())
      }

      terminal.println(TextColors.yellow("Sophi wants to run ${requests.size} actions this round:"))
      requests.forEach {
        terminal.println(TextColors.yellow("  - ${it.toolName} (${it.riskLevel}): ${it.preview ?: it.argumentsJson}"))
      }

      terminal.print(TextColors.yellow("Allow all? [y/N] "))

      val allowAll = input.awaitYesNo()
      return requests.associate { it.callId to allowAll }
     }
 }

// Nobody, ever — the safe default for a web endpoint or SDK caller with no UI to ask through:
ConfirmationPolicy.DENY_ALL

The third answer from a month earlier — a per-task allowlist for scheduled runs — didn’t survive as its own ConfirmationPolicy implementation. ADR-016 deleted AllowlistConfirmationPolicy and folded it straight into the loop itself:

Kotlin
class AgentLoop(
    // ...
    private val grants: Set<String> = emptySet(),
)

grants is the prep list a manager signs off on before leaving for the night, not a phone call they never pick up — a recurring monitoring task's toolGrants might list fetch_url but never write_file. It didn't need to be a ConfirmationPolicy at all, because "which tools may this scope skip asking about" isn't a yes/no decision made at call time — it's a fact about the scope, checked before the policy is ever consulted. The subagent-delegation tool builds one the same way, for the same reason: delegate_to_subagent takes an expected_tools argument, and that list becomes an ephemeral grant for the one subagent invocation it spawns.

That last detail hides the sharpest idea in ADR-016. The riskiest moment for a tool like manage_scheduled_task or delegate_to_subagent was never the call that uses power — it's the call that hands it out, because a scheduled task's grants get consulted with no human present, by definition, and the only point a human can actually look at them is while they're being set. So granting power is classified like using it:

Kotlin
override fun riskLevel(argumentsJson: String): RiskLevel {
  val args = runCatching {
    json.decodeFromString<SubagentArgs>(argumentsJson)
  }.getOrNull() ?: return RiskLevel.SAFE

  return fullRegistry.worstRiskAmong(args.expectedTools)
}

delegate_to_subagent's own riskLevel isn't a fixed tier — it's the worst tier among the tools it's about to hand a subagent. Ask it to grant write_file, and the call to ask inherits write_file's own risk. manage_scheduled_task does the same for toolGrants on a create/update call. Neither tool needed a parallel approval mechanism; both just answered the same riskLevel(argumentsJson) question honestly, using power the interface already had.

Teaching the manager which tickets don’t need a look

Three days after ADR-016, ADR-017 asked a harder question: even a CAUTION/DESTRUCTIVE tier is still a property of the tool, not the call. A single-file rm in a scratch directory and rm -rf / both land in bash's DESTRUCTIVE bucket and both always prompt — correct, but not free. Auto mode's job is to skip the prompt for calls that are genuinely boring, without ever silently waving through one that isn't.

The mechanism is one more method next to riskLevel, following the same pattern — tool-specific risk knowledge lives on the tool, not in a central rulebook:

Kotlin
interface Tool {
  // ...
  fun ruleVerdict(argumentsJson: String): RuleVerdict = RuleVerdict.UNKNOWN
  fun confirmationPreview(argumentsJson: String): String? = null
}

enum class RuleVerdict { LOW_RISK, HIGH_RISK, UNKNOWN }

bash again supplies the interesting case: a hardcoded list of destructive substrings (rm -rf, sudo , curl | sh) reads as HIGH_RISK outright, a lone rm targeting /tmp/ or ./scratch/ reads as LOW_RISK, and everything else — the vast, unclassified middle — stays UNKNOWN on purpose. UNKNOWN is the honest default: a tool that never overrides ruleVerdict() doesn't become auto-approved by omission; it just always defers.

Deferring to what, exactly, is the second half of the design — and it’s where the ConfirmationPolicy interface's boring shape pays out a second time. Auto mode isn't a change to AgentLoop at all. It's a new implementation that wraps an existing one:

Kotlin
class AutoModeConfirmationPolicy(
  private val registry: ToolRegistry,
  private val classifier: RiskClassifier,
  private val fallback: ConfirmationPolicy) : ConfirmationPolicy {
  override suspend fun confirm(requests: List<ConfirmationRequest>): Map<String, Boolean> {
    val (autoApproved, needsHuman) = requests.partition { resolve(it) == RuleVerdict.LOW_RISK }
    val humanResult = if (needsHuman.isEmpty()) emptyMap() else fallback.confirm(needsHuman)

    return autoApproved.associate { it.callId to true } + humanResult
  }

  private suspend fun resolve(request: ConfirmationRequest): RuleVerdict {
    val tool = registry.getOrNull(request.toolName) ?: return RuleVerdict.HIGH_RISK
    val verdict = tool.ruleVerdict(request.argumentsJson)

    return if (verdict != RuleVerdict.UNKNOWN) verdict else classifier.classify(tool.name, tool.description, request.riskLevel, request.argumentsJson)
  }
}
Doubt, at any stage of the path, always lands on a human — never on silent approval.
Doubt, at any stage of the path, always lands on a human — never on silent approval.

Rules first, LLM only when a tool has no opinion — deterministic checks stay fast and auditable for the common cases, and a single non-streaming completion call handles the novel ones rules haven’t been written for yet. Anything that isn’t cleanly LOW_RISK — including a classifier timeout, a malformed response, or a tool nobody registered — falls straight through to fallback, the same TerminalConfirmationPolicy from before. Fail-safe, not fail-open: doubt always resolves to asking a human, never to approving on its own. AgentLoop needed zero changes to gain this, because it already only ever calls confirmationPolicy.confirm(requests) on whatever it was handed — a fun interface with one method was, like Tool.execute() five ADRs earlier, a hole shaped exactly like "someone will want to swap this out later."

One more wrapper lets a running CLI session flip that switch without rebuilding the loop underneath it:

Kotlin
class ToggleableConfirmationPolicy(
  private val autoPolicy: ConfirmationPolicy,
  private val manualPolicy: ConfirmationPolicy,

  @Volatile var autoModeEnabled: Boolean) : ConfirmationPolicy {
    override suspend fun confirm(requests: List<ConfirmationRequest>): Map<String, Boolean> = (if (autoModeEnabled) autoPolicy else manualPolicy).confirm(requests)
}

/auto in the terminal flips one @Volatile var; AgentLoop holds the same ConfirmationPolicy reference for the whole session and never notices which inner policy it's delegating to this round. The manager didn't get replaced — it got a sous-chef who's memorized the boring tickets and only interrupts for the ones that are actually worth a look.

The same bet, three times over

ADR-005 made the same trade the day before ADR-006: legibility over cleverness, a boring shape that turns out to have room in it for things nobody asked for yet. Tool calls are strings you can log and paste into a bug report, the same way session entries are lines you can grep. Neither decision reached for a database, a schema registry, or a typed wrapper, and neither needed to.

What’s worth noticing weeks later is how many times that bet got cashed, and how the cost of cashing it kept dropping. suspend paid for parallel dispatch on day one, for free, because concurrency was the whole point of the choice. ADR-016 spent one real rewrite — riskLevel into a function, confirm() into a batch, AllowlistConfirmationPolicy folded into grants — to buy a risk-tiering system nobody had designed in June, still without touching a single existing tool's execute(). ADR-017 didn't even spend that: a one-method fun interface left just enough of a seam that an entire auto-approval subsystem with its own LLM classifier could wrap around it as a decorator, and AgentLoop needed zero changes to gain it. None of the three was foreseen when the other shipped. All three fit anyway, because the original decision was to leave room rather than to guess right.

What’s next?

The next article in this series covers building skill harness, or as we call the title: Rough-in work: a recipe box nobody was reading yet

Find the full source on GitHub and connect with me on LinkedIn.

All articles in the series