ZIO Integration
The llm4s-zio module wraps the synchronous LLMClient and Agent APIs in
ZIO effects, shifting blocking LLM calls to ZIO’s blocking
thread pool and surfacing LLMError directly in the ZIO error channel.
Dependency
1
2
// build.sbt
libraryDependencies += "org.llm4s" %% "llm4s-zio" % "<version>"
LLMClientZ
LLMClientZ is the ZIO wrapper for LLMClient.
Acquire via ZLayer
Use LLMClientZ.layer to load provider config from the environment, build the
client, and release it on scope exit:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.llm4s.agent.AgentContext
import org.llm4s.llmconnect.model.{Conversation, UserMessage}
import org.llm4s.toolapi.ToolRegistry
import org.llm4s.zio.LLMClientZ
import zio.{ZIO, ZIOAppDefault}
object MyApp extends ZIOAppDefault {
def run: ZIO[Any, Any, Any] =
(for {
client <- ZIO.service[LLMClientZ]
c <- client.complete(Conversation(Seq(UserMessage("What is 2 + 2?"))))
_ <- ZIO.debug(c.content)
} yield ()).provide(LLMClientZ.layer)
}
Wrap an existing client
1
2
3
import org.llm4s.zio.LLMClientZ
val wrapped: LLMClientZ = LLMClientZ(existingClient)
Streaming
streamComplete returns a ZStream[Any, LLMError, StreamedChunk] that delivers chunks
incrementally. The blocking provider call runs on an interruptible blocking thread and feeds a
bounded queue, so a slow consumer applies backpressure, and stopping early or interrupting the
fiber interrupts the call. If the call fails mid-stream, chunks already received are emitted
first and the stream then fails with the LLMError:
1
2
3
4
5
client
.streamComplete(conversation)
.map(_.content.getOrElse(""))
.runCollect
.map(_.mkString)
AgentZ
AgentZ wraps Agent, shifting the blocking agent loop to ZIO’s blocking pool.
LLMError is the native error type — no wrapping needed.
1
2
3
4
5
6
val agentZ = client.agent()
for {
state <- agentZ.run(query = "Summarise this", tools = myTools)
_ <- ZIO.debug(state.conversation.messages.last.toString)
} yield ()
Multi-turn conversations
1
2
3
4
for {
s1 <- agentZ.run("What's the weather in Paris?", tools)
s2 <- agentZ.continueConversation(s1, "And London?")
} yield s2
Cancellation
Cancellation is by thread interrupt, matching the llm4s core contract. LLMClientZ.complete, AgentZ and the streaming
methods all run the provider call on an interruptible blocking thread, so cancelling the fiber (or
a timeout) interrupts the call instead of waiting for it to finish.
Error handling
LLMError flows naturally in the ZIO error channel:
1
2
3
client.complete(conversation).catchAll { err =>
ZIO.debug(s"LLM error: ${err.message}") *> ZIO.fail(err)
}
Environment variables
See CLAUDE.md for the full list of supported environment
variables (LLM_MODEL, OPENAI_API_KEY, etc.).
Differences from Agent
AgentZ is a deliberately thin wrapper. run does not expose handoffs, and
continueConversation does not expose contextWindowConfig; the Agent defaults apply.
Tracing, debug logging and the trace log path are still available through the context
parameter (AgentContext). If you need handoffs or context-window pruning, call Agent directly
inside ZIO.attemptBlocking.
Tool calls are not a failure of the effect. When the model calls a tool with arguments that do not
fit the tool’s schema, Agent hands a structured error result back to the model so it
can correct itself; the run continues and only the step limit or a provider error ends it, which
then arrives in the error channel as usual. If you need to see those results, read the
ToolMessages in the returned AgentState. The schema-validated AgentTool contract and the
graph runtime (org.llm4s.agent.graph) are experimental and are not wrapped here; if you pass
handoffs by calling Agent directly, each Handoff needs a stable id, as in
Handoff.to("physics", agent).