Agent loop

Framework for building autonomous AI agents that iteratively solve tasks using tool calling. Provides a unified interface for OpenAI, Claude, Gemini, and custom backends.

Key Features:

  • Unified API for OpenAI, Claude, and Gemini

  • Type-safe tool definitions

  • Type-safe structured output (optionally)

  • Full execution history tracking

  • Support for Identity, cats-effect, ZIO, and other effect systems

  • Easy custom backend implementation

Quick Start

//> using dep com.softwaremill.sttp.ai::openai:0.11.0

import sttp.ai.core.agent.*
import sttp.ai.openai.OpenAI
import sttp.ai.openai.agent.OpenAIAgent
import sttp.ai.openai.requests.completions.chat.ChatRequestBody.ChatCompletionModel
import sttp.client4.DefaultSyncBackend
import sttp.tapir.Schema

object BasicExample extends App {
  case class WeatherInput(location: String) derives io.circe.Codec.AsObject, Schema

  val weatherTool = AgentTool.fromFunction(
    "get_weather",
    "Get the current weather for a location"
  ) { (input: WeatherInput) =>
    s"The weather in ${input.location} is 22°C, sunny"
  }

  val backend = DefaultSyncBackend()
  try {
    val agent = OpenAIAgent
      .synchronous(OpenAI.fromEnv, ChatCompletionModel.GPT4oMini)
      .maxIterations(5)
      .tools(weatherTool)
      .build

    val result = agent.run("What's the weather in Paris?")(backend)

    result.finalAnswer match {
      case Right(answer) => println(s"Answer: $answer")
      case Left(failure) => println(s"Agent did not finish cleanly: $failure")
    }
    println(s"Iterations: ${result.iterations}")
  } finally backend.close()
}

Multi-turn conversations

Every AgentResult carries the full ConversationHistory of the run — the user message, assistant responses, tool calls and their results, and the final answer. To continue talking with full context, pass it back to run together with the next user message:

//> using dep com.softwaremill.sttp.ai::openai:0.11.0

import sttp.ai.core.agent.*
import sttp.ai.openai.OpenAI
import sttp.ai.openai.agent.OpenAIAgent
import sttp.ai.openai.requests.completions.chat.ChatRequestBody.ChatCompletionModel
import sttp.client4.DefaultSyncBackend

object ChatExample extends App {
  val backend = DefaultSyncBackend()
  try {
    val agent = OpenAIAgent.synchronous(OpenAI.fromEnv, ChatCompletionModel.GPT4oMini).build

    val first = agent.run("My name is John Doe. What is 2+2?")(backend)
    println(first.finalAnswer)

    // seed the next run with the previous history: the model sees the whole conversation
    val second = agent.run("Multiply that by 10, and remind me of my name.", first.history)(backend)
    println(second.finalAnswer)

    // second.history extends first.history — inspect it, persist it, or feed it into another run
    second.history.entries.foreach(println)
  } finally backend.close()
}

A history can also be built (or restored, e.g. from a database) by hand via ConversationHistory.empty.addUserPrompt(...).addAssistantResponse(...) and passed as the seed of a fresh run.

For Claude: Use ClaudeAgent.synchronous(ClaudeConfig.fromEnv, ClaudeModel.ClaudeHaiku4_5.value) instead.

For Gemini: Use GeminiAgent.synchronous(GeminiConfig.fromEnv, "gemini-2.5-flash") — see Gemini tool calling for a full example.

For effect systems: use OpenAIAgent.builder[F] / ClaudeAgent.builder[F] / GeminiAgent.builder[F] (e.g. builder[IO]), then add configuration and .build.