Serving an Agent over HTTP
modules/examples-http holds small, tested reference apps that expose an llm4s agent from a Scala HTTP stack. Each
serves the same two endpoints, so they can be compared:
| Endpoint | Request | Response |
|---|---|---|
POST /chat |
{"message": "..."} |
{"threadId", "status", "answer"} as JSON, or an error status |
POST /chat/stream |
{"message": "..."} |
server-sent events of the answer as it is generated |
| App | Built on | Status |
|---|---|---|
| http4s (Ember) | llm4s-effect: AgentIO[IO], an fs2 Stream |
available |
| ZIO HTTP | llm4s-zio: AgentZ, ZStream |
planned (#1459) |
| Tapir | endpoint definitions with streaming bodies | planned (#1459) |
The module is not published: it is code to read and copy. Its HTTP dependencies stay in it.
Run it
With the fake client, which needs no model and no API key (it answers “You said: …”):
1
sbt "examplesHttp/run --fake"
With a model: the app loads the provider at startup through Llm4sConfig, from the named section
llm4s.providers.provider names. The module’s application.conf uses the samples’ default, a local Ollama
(ollama pull llama3 first, or set OLLAMA_MODEL), and has openai-main and anthropic-main sections that use
OPENAI_API_KEY and ANTHROPIC_API_KEY:
1
2
sbt examplesHttp/run
LLM4S_PROVIDER=openai-main sbt examplesHttp/run # with OPENAI_API_KEY set
--port N and --host H change the address (default 127.0.0.1:8080). Then:
1
2
curl -s localhost:8080/chat -d '{"message": "What is Scala?"}'
curl -N localhost:8080/chat/stream -d '{"message": "What is Scala?"}'
The stream
POST /chat/stream answers 200 with Content-Type: text/event-stream once the request is valid, then sends:
| Event | Data |
|---|---|
delta |
{"attempt", "text"}: the next piece of the answer. A higher attempt means the model call was retried; discard the earlier attempt’s text. |
tool_call |
{"id", "tool"}: the agent is calling a tool. |
done |
the /chat response body; always the last event of a run that ends. |
error |
the error body below, plus status (and retryAfterSeconds): the run failed. |
A : keep-alive comment is sent every 15 seconds while the run is quiet.
The http4s app turns AgentIO.stream - the run’s events, then its result - into ServerSentEvents:
1
2
3
4
5
6
7
8
9
10
agent
.stream(ThreadId(UUID.randomUUID().toString), message)
.collect {
case AgentStreamItem.Event(AgentEvents.TextDelta(d)) => event("delta", ChatProtocol.delta(d.attempt, d.text))
case AgentStreamItem.Event(AgentEvents.ToolCallStarted(t)) =>
event("tool_call", ChatProtocol.toolCall(t.toolCallId, t.tool))
case AgentStreamItem.Done(result) => event("done", ChatProtocol.result(result))
}
.handleErrorWith(error => Stream.emit(event("error", ChatProtocol.streamError(HttpErrors.of(llmError(error))))))
.mergeHaltL(Stream.awakeEvery[IO](heartbeat).as(ServerSentEvent(comment = Some("keep-alive"))))
The agent is built with withStreaming(), so its model calls stream and their text reaches the run’s events as
AgentEvents.TextDelta.
Cancellation
When the client disconnects, the run is cancelled and the provider call interrupted. The server learns of the
disconnect when a write fails; the heartbeat makes a write happen at least every 15 seconds, even while the model is
silent. The failed write interrupts the response stream, and interrupting AgentIO.stream cancels the run
(AgentRun.cancel), which interrupts its model call. Http4sChatServerSpec shows it: a provider call that parks after
its first chunk is interrupted once the test’s socket closes.
POST /chat runs the turn with AgentIO.run, which cancels the turn when its fiber is cancelled. Ember does not watch
a connection while it computes a response, so the turn of a client that leaves /chat runs to the end; use
/chat/stream when that matters, or bound the run with a timeout
(RunConfig().withBudgets(RunBudgets().withTimeout(...))).
Errors
An error is answered as {"error": {"type", "message", "recoverable"}}. HttpErrors.of chooses the status from the
error that failed the run, unwrapping the agent runtime’s GraphError.NodeFailed:
| Error | Status |
|---|---|
a body that is not {"message": "<non-blank>"}, ValidationError, InvalidInputError |
400 |
RateLimitError |
429, with Retry-After: the provider’s hint, else RateLimitError.DefaultRetryDelay |
TimeoutError |
504 |
AuthenticationError |
502: the provider refused the server’s credential, which the caller cannot fix |
ConfigurationError |
500 |
any other RecoverableError (network, provider 5xx) |
503 |
| anything else | 502 |
The message is the library’s, which can name the provider; a public service may want to log it and send less.
Testing
The tests need no API key and no network beyond the loopback. ChatRoutesSpec calls the routes in memory against a
ScriptedLLMClient from llm4s-testkit: the JSON answer, the order of the streamed
events, each error’s status, and the stream’s error event. Http4sChatServerSpec starts the server on an ephemeral
port and calls it over real sockets, including the disconnect above.
1
sbt examplesHttp/test
What a real service adds
These apps leave out authentication, rate limiting, request size limits, conversation history (each request starts a new thread in the agent’s in-memory store) and deployment. See Production Deployment for closing the client and the rest of running llm4s in production.