LLM Tool Use Without a Framework

The AgentScope chapter drives tools through a full agent SDK. That SDK supplies a ReAct loop, a JSON schema for every tool, and the message plumbing between them. At times that is more than you need. This chapter builds the same capability in pure Scala 3: a registry of named tools, three safe builtins, and a loop that lets any chat model call them through plain text. No SDK and no JSON schema dance. The model writes CALL: lines, the code runs them, and the results flow back into the current message buffer..

All code is in source-code/llm-tools.

What Tool Use Actually Is

A chat model is a text function. You give it text and it returns text. It cannot read a file, run a query, or check a clock. It can only predict the next token. Tool use removes that limit with a loop that lives in your program, not in the model. Each round has three parts, the same Reason, Act, Observe cycle the AgentScope chapter described:

  1. The host sends the model the task plus a description of the tools it may use.
  2. The model replies with text. If it wants a tool, the text names the tool and its arguments. The model does not run anything.
  3. The host parses that text, runs the named tool, and appends the result to the conversation. It then sends the longer conversation back to the model.
  4. The loop repeats until the model replies with a final answer.

The model’s only job is to produce the next piece of text. The host’s job is to read that text, decide whether it is a tool request or an answer, and act on it. The model chooses the tool and its arguments; the host runs it and reports back.

Two choices define such a loop: how the model asks for a tool, and how the host detects the request. A framework chooses structured function calling, where the model returns JSON that matches a schema the host supplied. We choose a plain text line protocol. The reason is practical. Small local models print clean lines far more often than they print valid JSON. A line such as CALL: read-file | notes.txt is easy for a four billion parameter model to produce and easy for the host to parse. The trade-off is less validation up front, so the parser and a fixed tool list have to carry the safety.

The description text you give the model is not documentation for humans. It is the exact information the model uses to decide when to call a tool. Writing a clear, short description is the real work of building an agent.

A Tool Registry in Thirty Lines

Every tool needs the same four things: a name the model will write, a one line description so the model knows when to use it, the names of its parameters, and a function that does the work. A case class holds them in Tool.scala:

 1 final case class ToolDefinition(
 2     name: String,
 3     description: String,
 4     parameters: List[String],
 5     run: List[String] => String
 6 )
 7 
 8 object ToolRegistry:
 9   private val tools = scala.collection.mutable.LinkedHashMap[String, ToolDefinition]()
10 
11   def register(tool: ToolDefinition): Unit = tools(tool.name) = tool
12 
13   def list: List[ToolDefinition] = tools.values.toList
14 
15   def execute(name: String, args: List[String]): String =
16     val tool = tools.getOrElse(name, throw new NoSuchElementException(s"Tool $name not found."))
17     if args.size != tool.parameters.size then
18       throw new IllegalArgumentException(
19         s"Tool $name needs ${tool.parameters.size} args (${tool.parameters.mkString(", ")}), got ${args.size}."
20       )
21     tool.run(args)
22 
23   // Render the registry as a prompt block the model can read.
24   def promptBlock: String =
25     list.map(t => s"- ${t.name}(${t.parameters.mkString(", ")}): ${t.description}").mkString("\n")

run takes a list of strings and returns a string. That uniformity is the point. Files, clocks, and web calls all fit the same shape. If a tool needs a number or a date, parse it at the edge inside run.

LinkedHashMap keeps insertion order, so promptBlock lists tools in the order you registered them, which keeps the prompt stable from run to run. execute does two checks before it runs anything. It fails loud on an unknown name and on the wrong number of arguments. Both checks matter. The model will make mistakes, and a clear exception message becomes useful text that the loop can feed back.

promptBlock renders the registry as a prompt block. This is the only place the model learns what tools exist, and the format mirrors the CALL line, so the description and the call syntax agree.

Three Safe Builtins

BuiltinTools.scala ports the Lisp originals from the llm-tools and cl-llm-agent projects in my Common Lisp book:

 1 object BuiltinTools:
 2   val currentTime: ToolDefinition = ToolDefinition(
 3     "current-time",
 4     "Return the current local date and time in ISO 8601 format. Takes no args.",
 5     Nil,
 6     _ => ZonedDateTime.now(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
 7   )
 8 
 9   val listFiles: ToolDefinition = ToolDefinition(
10     "list-files",
11     "List files in a directory, skipping hidden files. Args: directory-path.",
12     List("directory-path"),
13     args =>
14       val dir = Paths.get(args.head)
15       if !Files.isDirectory(dir) then throw new IllegalArgumentException(s"Directory not found: ${args.head}")
16       Files.list(dir).toArray.toList
17         .collect { case p: java.nio.file.Path => p.getFileName.toString }
18         .filterNot(n => n.startsWith(".") || n.endsWith("~"))
19         .sorted
20         .mkString("\n")
21   )
22 
23   val readFile: ToolDefinition = ToolDefinition(
24     "read-file",
25     "Return the text of a file. Args: file-path.",
26     List("file-path"),
27     args =>
28       val p = Paths.get(args.head)
29       if !Files.isRegularFile(p) then throw new IllegalArgumentException(s"File not found: ${args.head}")
30       Files.readString(p)
31   )
32 
33   def registerAll(): Unit =
34     ToolRegistry.register(currentTime)
35     ToolRegistry.register(listFiles)
36     ToolRegistry.register(readFile)

current-time takes no args and returns an ISO 8601 stamp with the machine’s offset. list-files takes a directory path, checks that it exists, lists one entry per line, and drops hidden files (names starting with a dot) and editor backups (names ending with a tilde). It sorts the result so the same directory always yields the same text, which helps when you compare runs. read-file takes a file path, checks that it is a regular file, and returns the whole text.

All three are read only. That is on purpose. An agent that can only read cannot wipe your disk while you test prompts. Add write tools later, and when you do, put them behind an allow list of paths.

registerAll puts the three into the registry. The demo and the tests both call it once at startup.

The CALL and FINAL Loop

The loop is the core of the example. It sends a prompt, reads the reply, and looks for two line shapes. CALL: name | arg runs a tool. FINAL: text ends the run. Everything else in the reply is ignored, so the model may add a sentence of reasoning if it wants. AgentLoop.scala declares the requests and ujson dependencies and starts with the prompt and the parser:

 1 //> using dep com.lihaoyi::requests:0.9.3
 2 //> using dep com.lihaoyi::ujson:4.4.3
 3 
 4 object AgentLoop:
 5   val callPrefix = "CALL:"
 6   val finalPrefix = "FINAL:"
 7 
 8   def systemPrompt(task: String): String =
 9     s"""You solve tasks by calling tools. Tools:
10 ${ToolRegistry.promptBlock}
11 Reply with one $callPrefix line per tool call, shaped like:
12 CALL: tool-name | arg1 | arg2
13 or give the answer shaped like:
14 FINAL: <answer text>
15 Task: $task"""
16 
17   // Parse one model reply into tool calls and an optional final answer.
18   def parseReply(reply: String): (List[(String, List[String])], Option[String]) =
19     val calls = reply.linesIterator.toList.collect {
20       case line if line.trim.startsWith(callPrefix) =>
21         val parts = line.trim.stripPrefix(callPrefix).split("\\|").map(_.trim).toList
22         (parts.head, parts.tail)
23     }
24     val finalAnswer = reply.linesIterator.toList
25       .find(_.trim.startsWith(finalPrefix))
26       .map(_.trim.stripPrefix(finalPrefix).trim)
27     (calls, finalAnswer)

parseReply works line by line. A line that starts with CALL: is trimmed, then split on the pipe. The first part is the tool name and the rest are arguments. A line that starts with FINAL: yields the answer text. Two details make this forgiving. The trim runs before the split, so a trailing space is harmless. Scala’s split also drops a trailing empty field, so a model that writes CALL: current-time | still sends zero arguments, which is what current-time expects.

run ties it together:

 1   // Run one task. Returns the final answer text.
 2   def run(task: String, model: String = "mistral", maxSteps: Int = 5): String =
 3     import scala.util.boundary, boundary.break
 4     boundary:
 5       var transcript = systemPrompt(task)
 6       for _ <- 1 to maxSteps do
 7         val reply = askModel(transcript, model)
 8         val (calls, finalAnswer) = parseReply(reply)
 9         finalAnswer match
10           case Some(answer) => break(answer)
11           case None =>
12             val results = calls.map { case (name, args) =>
13               val out =
14                 try ToolRegistry.execute(name, args)
15                 catch case e: Exception => s"ERROR: ${e.getMessage}"
16               s"Result of $name: $out"
17             }
18             transcript += s"\nModel said:\n$reply\n${results.mkString("\n")}\nContinue with CALL or FINAL lines."
19       throw new IllegalStateException(s"No FINAL answer within $maxSteps steps.")

The transcript starts as the system prompt. Each round asks the model, parses the reply, and checks for a final answer. If there is one, boundary and break return it at once. If not, every call runs and its output joins the transcript under a Result of line. The next round sees the task, its own earlier reply, and the tool results. That growing transcript is the model’s whole memory of the session, so the model does not need any memory of its own.

The try around each call is important. A tool that raises does not crash the loop. Its message becomes ERROR: text that the model reads on the next round. A missing file or a wrong argument count then becomes something the model can react to, either by fixing the arguments or by telling the user what went wrong. This is the same shape the book’s coding agent uses.

maxSteps bounds the loop. Without it, a model that never prints FINAL: would spin forever. Five steps is enough for the small tasks here.

The last piece talks to the model:

1   private def askModel(prompt: String, model: String): String =
2     val payload = ujson.Obj("model" -> model, "prompt" -> prompt, "stream" -> false).render()
3     val response = requests.post(
4       "http://localhost:11434/api/generate",
5       headers = Map("Content-Type" -> "application/json"),
6       data = payload,
7       readTimeout = 120000
8     )
9     ujson.read(response.text())("response").str

It POSTs to Ollama’s /api/generate endpoint with stream set to false, so the server buffers the whole reply and returns one JSON object. The loop reads the response field. Any chat model with the same endpoint, or a different client that returns text, can drive this loop unchanged. That is what model agnostic means here.

The Demo

Main.scala registers the builtins, prints the prompt block, runs two tools directly to show they work, then hands a task to the loop:

 1 @main def toolsDemo(args: String*): Unit =
 2   BuiltinTools.registerAll()
 3   println("Registered tools:")
 4   println(ToolRegistry.promptBlock)
 5   println("\n--- current-time ---")
 6   println(ToolRegistry.execute("current-time", Nil))
 7   println("\n--- list-files . ---")
 8   println(ToolRegistry.execute("list-files", List(".")))
 9   val model = if args.nonEmpty then args(0) else "mistral"
10   val task = "What time is it and what files sit here?"
11   println(s"\n--- agent run (needs local Ollama, model $model): $task ---")
12   try println("FINAL: " + AgentLoop.run(task, model = model))
13   catch case e: Exception => println(s"[skip live loop] ${e.getMessage}")

The optional command-line argument lets you point the live loop at any model you have pulled. With no argument it uses mistral, the default in AgentLoop.run.

The direct calls need no model, so they run even when Ollama is down:

 1 Registered tools:
 2 - current-time(): Return the current local date and time in ISO 8601 format. Takes no args.
 3 - list-files(directory-path): List files in a directory, skipping hidden files. Args: directory-path.
 4 - read-file(file-path): Return the text of a file. Args: file-path.
 5 
 6 --- current-time ---
 7 2026-09-10T13:54:06.577059-07:00
 8 
 9 --- list-files . ---
10 AgentLoop.scala
11 BuiltinTools.scala
12 Main.scala
13 Makefile
14 README.md
15 Tool.scala
16 ToolTest.scala
17 
18 --- agent run (needs local Ollama, model mistral): What time is it and what files sit here? ---
19 [skip live loop] Request to http://localhost:11434/api/generate failed with status code 404
20 {"error":"model 'mistral' not found"}

The 404 is expected when the default model is not pulled. AgentLoop.run defaults to mistral, so pull it, or pass a model you already have as the demo’s argument. With a small model such as qwen3.5:4b pulled, run the demo like this:

1 scala-cli run . --main-class llmtools.toolsDemo -- qwen3.5:4b

The same task then drives the loop and prints:

1 FINAL: It is 2026-09-10T13:57:16 (UTC-7, Pacific Time) and the files here are: AgentLoop.scala,
2 BuiltinTools.scala, Main.scala, Makefile, README.md, Tool.scala, and ToolTest.scala

Behind that answer the loop made two rounds. The first reply was:

1 CALL: current-time |
2 CALL: list-files | .

The host ran both, put the two results in the transcript, and asked again. The second reply was the FINAL: line above. The model chose which tools to call and what arguments to pass, and it merged two results into one sentence. We wrote no code for that part. Small models vary from run to run, so the exact wording changes, but the shape stays the same: one round of calls, then one final answer.

The error path matters just as much. Asked to read a file that does not exist, the model calls read-file, the tool raises, and the loop feeds back ERROR: File not found: nope.txt. On the next round the model answers:

1 FINAL: The file nope.txt was not found. It doesn't exist in the directory being searched.

A framework would surface that failure as a tool result too. Doing it by hand shows the mechanism plainly: the error is just more text in the transcript.

Offline Tests

ToolTest.scala runs on a temp directory and needs no model:

 1 @main def toolsTest(): Unit =
 2   BuiltinTools.registerAll()
 3 
 4   // Registry holds the three builtins.
 5   val names = ToolRegistry.list.map(_.name).toSet
 6   assert(names == Set("current-time", "list-files", "read-file"), s"registry wrong: $names")
 7 
 8   // Unknown tool and wrong arity raise.
 9   try { ToolRegistry.execute("nope", Nil); assert(false, "unknown tool must raise") }
10   catch case _: NoSuchElementException => ()
11   try { ToolRegistry.execute("read-file", Nil); assert(false, "wrong arity must raise") }
12   catch case _: IllegalArgumentException => ()
13 
14   // current-time returns an ISO-8601 stamp.
15   val stamp = ToolRegistry.execute("current-time", Nil)
16   assert(stamp.matches("\\d{4}-\\d{2}-\\d{2}T.*"), s"bad timestamp: $stamp")
17 
18   // list-files sees this dir, skips hidden files; read-file round trips.
19   val dir = Files.createTempDirectory("tools-test")
20   Files.writeString(dir.resolve("a.txt"), "hello")
21   Files.writeString(dir.resolve(".hidden"), "x")
22   val listing = ToolRegistry.execute("list-files", List(dir.toString))
23   assert(listing.contains("a.txt") && !listing.contains(".hidden"), s"bad listing: $listing")
24   assert(ToolRegistry.execute("read-file", List(dir.resolve("a.txt").toString)) == "hello", "read-file wrong")
25   try { ToolRegistry.execute("read-file", List(dir.resolve("missing.txt").toString)); assert(false, "missing file must raise") }
26   catch case _: IllegalArgumentException => ()
27 
28   // Reply parser: CALL lines become calls, FINAL line becomes the answer.
29   val (calls, fin) = AgentLoop.parseReply("CALL: read-file | a.txt\nFINAL: done here")
30   assert(calls == List(("read-file", List("a.txt"))), s"calls wrong: $calls")
31   assert(fin.contains("done here"), s"final wrong: $fin")
32   val (noCalls, noFin) = AgentLoop.parseReply("just thinking out loud")
33   assert(noCalls.isEmpty && noFin.isEmpty, "plain text parses to nothing")
34 
35   // Prompt block names every tool so the model can see them.
36   assert(AgentLoop.systemPrompt("t").contains("read-file"), "system prompt must list tools")
37 
38   println("All tool tests passed.")

The checks pin each part of the design. They confirm the registry holds exactly the three builtins; that an unknown tool raises NoSuchElementException and a wrong argument count raises IllegalArgumentException; that current-time returns a stamp matching YYYY-MM-DDTHH:MM:SS; that list-files sees a written file but skips a hidden one; that read-file round trips a written file and raises on a missing one; that parseReply turns a CALL: line into a call, a FINAL: line into the answer, and plain text into nothing; and that the system prompt names every tool. The last check is the one that guards the contract between the prompt and the parser. If you rename a tool or drop it from the prompt, the test fails before the model ever sees it.

Run the demo and the checks:

1 cd source-code/llm-tools
2 scala-cli run . --main-class llmtools.toolsDemo
3 scala-cli run . --main-class llmtools.toolsTest

The checks end with:

1 All tool tests passed.

Wrap Up

This chapter built a tool loop with no framework. The pieces are small: a case class for a tool, a map for the registry, three read only builtins, and a loop that parses two line prefixes. The model writes CALL: lines to act and a FINAL: line to stop. The host runs the tools and feeds the results back as text, so a failure becomes something the model can see and correct on the next round.

The text protocol is not the only way. The AgentScope chapter used structured function calling, where the model returns JSON that matches a schema. Structured calls are more robust when the model supports them well. A plain line protocol needs no schema and works with small local models that struggle with JSON. Knowing both lets you pick the lighter tool when it fits, and it shows what a framework does for you when you would rather not write it yourself.

Optional Practice Problems

  1. Add a tool. Register a word-count tool that takes a file path and returns the number of lines and words. Add a test that writes a file and checks the counts, then ask the agent for the word count of a file in this directory.

  2. Multiple arguments. Add a grep-file tool with two parameters, pattern and file-path, that returns the matching lines. Call it from a test and from the agent, and confirm the arity check rejects a one argument call.

  3. A write tool with an allow list. Add a write-file tool that refuses any path outside a configured base directory. Test both an allowed and a rejected path. Explain in one sentence why the allow list matters even when you trust the model.

  4. Cap the output. Large tool results crowd the transcript and cost tokens. Add a limit that truncates each result to a fixed number of characters and appends a marker when it cuts. Run the agent on a large file and compare behavior with and without the cap.

  5. JSON instead of lines. Add a second parser that reads a reply shaped like {"action":"read-file","args":["a.txt"]} and wire the loop to accept either shape. Compare how often a small local model produces valid JSON against how often it produces a clean CALL: line.

  6. Record the run. Have the loop return the full transcript, not just the final answer, and print it in the demo. Use it to see how many rounds a task takes and what the model saw at each step.