Build AI agents in Scala
To build an AI agent in Scala, add llm4s-agent and give Agent.builder a model client and the tools the model may
call: the agent loops between the model and your tools until it has an answer. The example below adds an input
guardrail and streams the agent’s events, in about 25 lines.
Add the modules
The agent runtime is its own module, next to the core and the module of your model provider:
1
2
3
4
libraryDependencies ++= Seq(
"org.llm4s" %% "llm4s-agent" % "0.5.1",
"org.llm4s" %% "llm4s-openai" % "0.5.1"
)
llm4s-agent brings llm4s-core. The client is built from your configuration as on the
first-call page, and you can test the agent with no model at all using
llm4s-testkit.
An agent with a tool, a guardrail and streaming
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.llm4s.agent.{ Agent, AgentResult }
import org.llm4s.agent.events.AgentEvents
import org.llm4s.agent.graph.ThreadId
import org.llm4s.agent.graph.middleware.GuardrailMiddleware
import org.llm4s.agent.guardrails.builtin.{ LengthCheck, ProfanityFilter }
import org.llm4s.llmconnect.LLMClient
import org.llm4s.toolapi.{ Schema, ToolBuilder, ToolRegistry }
import org.llm4s.types.Result
def weatherAgent(client: LLMClient, question: String): Result[AgentResult] = for {
forecast <- ToolBuilder[Map[String, Any], String](
name = "get_forecast",
description = "Returns today's forecast for a city",
schema = Schema
.`object`[Map[String, Any]]("Forecast parameters")
.withProperty(Schema.property("city", Schema.string("The city to look up")))
).withHandler(args => args.getString("city").map(city => s"Sunny and 18 C in $city")).buildSafe()
agent <- Agent
.builder("weather", client)
.withSystemPrompt("You answer questions about the weather.")
.withTools(new ToolRegistry(Seq(forecast)))
.withMiddleware(new GuardrailMiddleware(input = Seq(new LengthCheck(1, 2000), new ProfanityFilter()), Nil))
.withStreaming()
.build()
run <- agent.stream(ThreadId("weather-1"), question) {
case AgentEvents.TextDelta(delta) => print(delta.text)
case AgentEvents.ToolCallStarted(tc) => println(s"[calling ${tc.tool}]")
case _ => ()
}
result <- run.await()
} yield result
What each part does:
- The tool.
ToolBuilderdeclares a function the model may call, with a JSON schema for its arguments; the handler reads its arguments asEithervalues, so a missing argument is a value, not an exception. The tools guide goes further, and typed tools derive the schema from a case class. - The guardrails.
GuardrailMiddlewarechecks the input before the model sees it. A question outside the length limit ends the run asAgentStatus.Blocked, an outcome you match on rather than an error, and the model is never called. Output guardrails, LLM-as-judge checks and your own are in the guardrails guide. - Streaming.
withStreaming()streams the model’s answer, andagent.streamhands every event of the turn to your function: text as it arrives, tool calls as they start, and more (streaming events). - The result.
AgentResultcarries the status, the thread’s messages and the token usage;result.answeris the final answer when the run completed. A provider or tool failure is aLeft.
The run belongs to a thread (ThreadId): call agent.run(threadId, ...) again with the same id to continue the
conversation.
Going further
- Agents guide: threads and results, typed answers, tool permissions and timeouts, context window management and conversation persistence.
- Handoffs between agents, human review (approving a tool call, for example), and memory.
- Durable checkpointers, so a run resumes after a crash or a deploy.
- The cookbook: runnable recipes for tool calling, guardrails, streaming and a multi-agent graph.
- With cats-effect or ZIO:
AgentIOandAgentZ.