A type-safe Scala.js wrapper for @anthropic-ai/claude-agent-sdk, with idiomatic ZIO, forward-compatible message modeling, and policy-driven collection for long-running agent workloads.
- SDK baseline:
@anthropic-ai/claude-agent-sdk0.2.75 - Current repo build version:
0.4.1 - Scala version:
3.7.4 - Preferred JS runtime:
bun - Runtime requirement:
ANTHROPIC_API_KEY
For a compatibility inventory against the installed SDK baseline, see docs/COMPATIBILITY.md.
ZStream-based query and session APIs- Forward-compatible message ADT with
UnknownEnvelopefallbacks - Deterministic query/session cleanup with idempotent
close()behavior - Policy-driven collection via
CollectionPolicyandQueryCollector - Semantic
ask()helpers that prefer final result payloads over transcript concatenation - Main-thread skill preloading via
AgentOptions.withSkills(...) - Structured outputs with compile-time schema derivation
- Tool DSL, hooks, MCP server config, and multi-turn sessions
Use the current published release when consuming from Maven Central. For local development against this repo, the build version is 0.4.1.
def ivyDeps = Seq(
mvn"com.tjclp::scalagent::0.4.1"
)libraryDependencies += "com.tjclp" %%% "scalagent" % "0.4.1"<dependency>
<groupId>com.tjclp</groupId>
<artifactId>scalagent_sjs1_3</artifactId>
<version>0.4.1</version>
</dependency>- Mill
- Bun or Node.js 18+
- Scala
3.7.4 ANTHROPIC_API_KEY
import com.tjclp.scalagent.*
import zio.*
object MyApp extends ZIOAppDefault:
val run =
for
answer <- Claude.ask("What is 2 + 2?")
_ <- Console.printLine(s"Answer: $answer")
yield ()import com.tjclp.scalagent.*
import zio.*
object StreamingApp extends ZIOAppDefault:
val run =
Claude.query("Count from 1 to 5, one number per line")
.textOnly
.foreach(text => Console.print(text).orDie)import com.tjclp.scalagent.*
import zio.*
object ConversationApp extends ZIOAppDefault:
val run =
for
session <- ClaudeSession.create(AgentOptions.default.withModel(Model.Sonnet4_6))
_ <- session.send("Remember the number 42").runDrain
answer <- session.ask("What number did I ask you to remember?")
_ <- Console.printLine(s"Claude remembered: $answer")
yield ()import com.tjclp.scalagent.*
import zio.*
import zio.json.*
case class Analysis(summary: String, score: Int, suggestions: List[String]) derives JsonDecoder
given StructuredOutput[Analysis] = StructuredOutput.derive[Analysis]
object AnalysisApp extends ZIOAppDefault:
val run =
val options = AgentOptions.default
.withModel(Model.Sonnet4_6)
.withStructuredOutput[Analysis]
for
result <- Claude.queryComplete("Analyze this code...", options)
analysis <- ZIO.fromEither(result.outcome.parseAs[Analysis])
_ <- Console.printLine(s"Analysis: $analysis")
yield ()queryComplete() and session collection are policy-driven so callers can control transcript retention explicitly.
import com.tjclp.scalagent.*
import zio.*
val options = AgentOptions.default.withPromptSuggestions
val effect =
for
result <- Claude.queryComplete(
prompt = "Summarize the repository status",
options = options,
collectionPolicy = CollectionPolicy.BoundedRecent(
limit = 12,
includeStreamingDeltas = false,
stopAtResult = true
)
)
answer <- result.semanticTextOrFail
yield answerAvailable policies include:
CollectionPolicy.FullCollectionPolicy.NoStreamingDeltasCollectionPolicy.UntilResultCollectionPolicy.ResultOnlyCollectionPolicy.SummaryOnlyCollectionPolicy.DisabledCollectionPolicy.BoundedRecent(...)
Top-level preloaded skills are exposed directly on AgentOptions.
val options = AgentOptions.default
.withSkills("slides", "spreadsheets")
.withSettingSources(SettingSource.User, SettingSource.Project)Compatibility behavior:
- preferred path: augment or synthesize a main-thread agent using SDK-native
agent/agents/AgentDefinition.skills - fallback path: resolve
SKILL.mdfiles from configured setting sources and inject them into the effective system prompt - existing runtime skill loading via
withSkillsEnabledremains available
scalagent preserves known SDK message variants and degrades gracefully when the SDK evolves.
Important properties:
- unknown top-level messages become
AgentMessage.Unknown - unknown system events become
SystemEvent.Unknown - unknown content blocks become
ContentBlock.Unknown - unknown deltas become
StreamDelta.Unknown - unknown variants preserve raw JSON and envelope metadata in
UnknownEnvelope - image content blocks are parsed into
ContentBlock.Image - unrecoverable malformed payloads become
AgentError.MessageParseError
QueryStream and ClaudeSession are hardened for long-running use:
- stream termination runs cleanup automatically
- early consumer termination triggers generator cleanup
interrupt()attempts interruption and then cleanupclose()is idempotent- cleanup failures are recorded and surfaced as warnings without masking the primary outcome
Use AgentOptions to configure queries:
val options = AgentOptions.default
.withModel(Model.Sonnet4_6)
.withMaxTurns(10)
.withMaxBudgetUsd(0.50)
.withPermissionMode(PermissionMode.AcceptEdits)
.withMcpServer("myserver", McpServerConfig.stdio("node", "server.js"))Common builder methods include:
withModel(...)withSystemPrompt(...)withMaxTurns(...)withMaxBudgetUsd(...)withPermissionMode(...)withAllowedTools(...)withMcpServer(...)withStructuredOutput[T]withMainAgent(...)withSkills(...)withSkillsEnabledwithFileCheckpointingwithFallbackModel(...)
for
queryStream <- ClaudeAgent.queryRaw("Complex task...")
fiber <- queryStream.messages.foreach(handleMessage).fork
_ <- ZIO.sleep(30.seconds)
_ <- queryStream.interrupt
_ <- fiber.join
yield ()| Method | Description |
|---|---|
interrupt |
Interrupt the running query |
close() |
Abort the query and terminate resources |
setPermissionMode(...) |
Change permission handling |
setModel(...) |
Change the active model |
supportedModels |
Inspect SDK-supported models |
mcpServerStatus |
Inspect MCP connection state |
rewindFiles(...) |
Rewind tracked files |
setMcpServers(...) |
Reconfigure MCP servers dynamically |
import com.tjclp.scalagent.*
object MyTools:
@Tool("get_weather", "Get the current weather for a location")
def getWeather(
@Param("City or location name") location: String,
@Param("Temperature unit") unit: Option[String] = None
): String =
s"Weather in $location: 22°${unit.getOrElse("C")}"bun install
./mill --no-server agent.compile
./mill --no-server agent.test
./mill examples.list
EXAMPLE=simple ./mill --no-server examples.runscalagent/
├── build.mill
├── package.json
├── docs/
├── examples/
├── src/com/tjclp/scalagent/
│ ├── config/
│ ├── messages/
│ ├── streaming/
│ ├── tools/
│ └── ClaudeAgent.scala
└── test/src/com/tjclp/scalagent/
MIT