Calling Claude from Scala with the official Java SDK
There is no official Scala SDK for Claude. I wanted to know whether that matters, so I built a small Play 3 app that calls Claude through the official Java SDK, com.anthropic:anthropic-java. Short answer: it doesn't matter much. The Java SDK works well from Scala once you know a handful of interop moves, and you need to fix one classpath trap that compiles fine and would fail later.
I built it as a learning exercise: Claude Code explained each step and I typed the Scala myself. The code is on GitHub at zakariahere/claude-sdk-scala-play.
The stack
- Play 3.0.12, sbt 1.12.15, Scala 2.13.18, JDK 25
anthropic-java2.73.0, using the async OkHttp client- Guice for runtime DI, play-json, ScalaTest
One dependency, and a single %
libraryDependencies += "com.anthropic" % "anthropic-java" % "2.73.0"%% tells sbt to append your Scala binary version (_2.13) to the artifact name. That only works for Scala libraries. anthropic-java is a Java/Kotlin artifact with no Scala suffix, so it takes a single %. sbt compile went green on the first try. That is where the real problem was hiding.
It compiled. The classpath was still mixed.
The SDK is written in Kotlin and serialises with Jackson. Play also brings Jackson. When two libraries ask for different versions of one artifact, sbt keeps the highest, but it decides per artifact, not per family. I exported the classpath to see what had actually been resolved:
$cp = sbt -batch "export Compile/dependencyClasspath" | Select-Object -Last 1; $cp -split ';' | Split-Path -Leaf | Where-Object { $_ -match 'jackson|kotlin' } | Sort-Object
jackson-databind up to 2.19.4. The Jackson modules that only Play uses stayed at 2.14.3.jackson-module-scala checks, when it registers, that databind is on its own minor version. A 2.14 module next to databind 2.19 is the kind of mismatch that passes every compile and fails the first time something at runtime uses it. The fix is to pin the whole family with dependencyOverrides:
val jacksonVersion = "2.19.4"
val kotlinVersion = "1.9.25"
dependencyOverrides ++= Seq(
"com.fasterxml.jackson.core" % "jackson-databind" % jacksonVersion,
"com.fasterxml.jackson.module" %% "jackson-module-scala" % jacksonVersion, // the only Scala artifact
"com.fasterxml.jackson.module" % "jackson-module-kotlin" % jacksonVersion,
"org.jetbrains.kotlin" % "kotlin-stdlib" % kotlinVersion,
// ... every jackson-* and kotlin-* artifact, one version each
)I ran the same export again: every jackson-* jar showed 2.19.4 and every kotlin-* jar showed 1.9.25. I took this approach from another Play project of mine that runs the same SDK in production and pins the same way.
Settings that fail fast
ai.anthropic {
api-key = ${?ANTHROPIC_API_KEY}
model = "claude-haiku-5-5"
max-tokens = 1024
}The ? matters. ${VAR} makes the whole config fail to load when the variable is missing, so even sbt test would break on a machine without a key. ${?VAR} just leaves the key unset. A small ClaudeSettings.fromConfig then reads the block into a case class and throws a PlayException that names ANTHROPIC_API_KEY when the key is missing or blank. That was my first ScalaTest spec, and it covers three cases: the key is present, missing, or whitespace. The key goes to the builder explicitly, so Play config is the only place it comes from.
The SDK lives behind one trait

trait ClaudeClient {
def ask(prompt: String): Future[String]
}
class ClaudeModule extends AbstractModule {
override def configure(): Unit =
bind(classOf[ClaudeClient]).to(classOf[AnthropicClaudeClient])
}AnthropicClaudeClient is a @Singleton and the only class that creates AnthropicOkHttpClientAsync. The client owns connection pools and threads, so it is built once and closed through ApplicationLifecycle.addStopHook. Without that, every dev-mode reload would leak threads. The trait is also the test seam: an anonymous new ClaudeClient { ... } is a fake with no network calls and no tokens spent.
The call itself

FutureConverters, OptionConverters and CollectionConverters cover almost all of the interop.The SDK has no case classes, so requests are built with Java builders. create returns a CompletableFuture, and .asScala turns it into a Scala Future. A response is a java.util.List of content blocks, and each block may or may not be text. block.text() returns a Java Optional, so .toScala followed by flatMap keeps only the text blocks in one step. maxTokens takes a long, so the settings field is a Long from the start.
Async end to end, JSON errors
def ask(): Action[AskRequest] = Action.async(parse.json[AskRequest]) { request =>
claude.ask(request.body.prompt)
.map(answer => Ok(Json.obj("answer" -> answer)))
.recover { case e: AnthropicServiceException =>
BadGateway(Json.obj("error" -> s"Claude API returned ${e.statusCode()}"))
}
}With the async client and Action.async, no Play thread waits while Claude generates. parse.json[AskRequest] rejects a bad body before my code runs. Play's default error handler renders HTML, which is wrong for an API, so one line switches it: play.http.errorHandler = "play.api.http.JsonHttpErrorHandler". Errors from the Claude API become a 502 with a JSON body.
The first answer
$ curl -s -X POST http://localhost:9000/ask -H 'Content-Type: application/json' \
-d '{"prompt":"Explain a Scala Option in one sentence"}'
{"answer":"A Scala `Option` is a container type that holds either a value wrapped in `Some` or nothing at all represented by `None`, letting you safely handle missing or nullable values without using `null`."}Fittingly, the answer was about the same Option type I had just used to read it.
What is not done yet
- The controller spec with the fake client is still a TODO, and so is returning 400 for a blank prompt.
- The client has no explicit timeout or retry policy, and it ignores
stopReason, so aMAX_TOKENScut-off currently looks like a normal answer. - No streaming yet.
createStreaminginto a Pekko StreamsSourceis the next part.
If you want a pure-Scala client instead, SoftwareMill's sttp-ai has a Claude module that implements the HTTP API itself. I went with the official Java SDK because it gets new API features as soon as Anthropic ships them, and from Scala it only costs three imports and one dependencyOverrides block.