Engineering · Jean-François Arcand ·
Atmosphere 4 released, now shipping a portable AI agent component
I added an AI agent layer to Atmosphere. Wiring up the LLM was the easy part — the streaming was already done.
I've worked on Atmosphere for years — a real-time transport for the JVM. It streams events from the server to connected clients, auto-negotiating WebSocket, SSE, or long-polling — with gRPC and WebTransport/HTTP3 available as separate transports — and handling the parts that break at scale: reconnects, write-timeouts, and backpressure when a client falls behind. It runs on Spring Boot, Quarkus, or a plain servlet container. Atmosphere 4 adds an AI agent layer on top of that transport.
Developers are right to be skeptical of yet another AI wrapper, so let me be clear about what this is. The call to the model is the easy bit. The hard part is delivering a token stream — plus tool-call events, structured-output frames, and multi-agent handoffs — to many concurrent clients, live, without dropping connections. That's the problem Atmosphere already solved, so I plugged the LLM into the streaming layer that was already there instead of building a new framework around the LLM.
One annotation, twelve runtimes
The annotation that matters is @Agent — drop it on a class and that
class is an agent. Its persona and instructions live in a skill file, it keeps
track of the conversation for you, and it shows up over A2A, MCP, and AG-UI without
a line of plumbing. So, not a hello-world — here's one worth looking at: a
billing-support agent that streams its reply and gates a money-moving tool behind a
human:
@Agent(name = "support",
skillFile = "skill:support-agent", // persona + instructions live in the SKILL.md
description = "Billing and refund support")
public class SupportAgent {
@AiTool(name = "issue_refund", description = "Refund a customer order")
@RequiresApproval("This refund posts immediately. Approve?")
public String issueRefund(@Param(value = "orderId", description = "Order to refund") String orderId) {
return billing.refund(orderId); // runs only after a human approves
}
@Prompt
public void onPrompt(String message, StreamingSession session) {
session.stream(message); // streams the reply to this client, token by token
}
}
That's the whole class. The persona lives in skill:support-agent —
the same SKILL.md format from the Skills section, so the agent stays a
shareable artifact. @RequiresApproval parks the refund tool — the
pipeline suspends the virtual thread and sends an approval request to the client;
the tool runs only if the human approves, and a denial or timeout resumes the call
without running it. None of that is glue you write; it's the annotations.
That one line in @Prompt deserves a closer look, because it doesn't
behave like a normal method call. session.stream(message) hands the
message to the resolved runtime and streams the model's reply back to this
client over whatever transport it connected on — token by token, as they're
generated. It returns void: there's no Future to await
and no response object to inspect, because the reply goes out over the wire, not
back through your method. The body runs on a virtual thread, so
you write plain blocking-looking code that costs almost nothing to park — which is
exactly what lets @RequiresApproval suspend the call mid-flight and
resume on a human's answer with no callback in sight, and what lets the framework
cancel the in-flight call for you if the client disconnects.
By default this runs on a built-in, dependency-free OpenAI-compatible
client. The part I care about is what happens next. Atmosphere has a single
AgentRuntime SPI. Drop a different Maven artifact on the classpath
and the same code above runs on a different runtime:
<!-- Built-in OpenAI-compatible client by default — no dependency.
Swap the runtime without touching a line of the code above: -->
<dependency>
<groupId>org.atmosphere</groupId>
<artifactId>atmosphere-langchain4j</artifactId>
<!-- or any one of:
atmosphere-spring-ai atmosphere-anthropic
atmosphere-spring-ai-alibaba atmosphere-cohere
atmosphere-adk atmosphere-koog
atmosphere-embabel atmosphere-semantic-kernel
atmosphere-agentscope atmosphere-crewai -->
</dependency>
And this is the part that matters most: that dependency line is the
only thing that changes. Look back at the agent — it imports
@Agent, @AiTool, @RequiresApproval,
StreamingSession: all org.atmosphere types. It never
imports a Spring AI ChatClient or a LangChain4j
ChatModel. Your agent has no compile-time dependency on the
framework underneath it — which runtime you pick is a deployment detail
behind the AgentRuntime SPI, not something woven through your code. That's what
makes an agent portable: you can move it from LangChain4j to Spring AI to the
built-in client, or hand it to someone running a different stack, and the class
doesn't change. It's the same bet Atmosphere has made from the start. Its transport
layer carried application code across WebSocket, Server-Sent Events, and
long-polling before the Java WebSocket spec existed — and to this day it does more
than the spec, with the transport fallback and reconnect the standard never
covered. The AI runtimes are that same idea one layer up: write to the abstraction,
swap the implementation — and tap a framework's native features when you need them,
without giving up that portability.
Your agent runs on any of twelve contract-tested runtimes. Nine wrap an AI
framework you already use — Spring AI, Spring AI Alibaba, LangChain4j, Google ADK,
Embabel, JetBrains Koog, Microsoft Semantic Kernel, AgentScope, and CrewAI — and
three talk straight to a model API with no framework at all: the built-in
OpenAI-compatible client, Anthropic, and Cohere. CrewAI is the one that isn't on
the JVM — it runs as an out-of-process Python sidecar the JVM
reaches over HTTP and SSE, gated on a live health check, so a Python-native agent
stack is reachable from the same @Agent without loading Python into
your JVM. The highest-priority available runtime wins; your endpoint code never
changes. By default each client gets its own streaming session; for shared rooms
you opt into a broadcaster and one reply fans out to every subscriber.
Why this isn't another LLM library
Spring AI and LangChain4j are excellent abstractions over model APIs — very good at "talk to a model." Atmosphere isn't trying to compete with them on how many models they support, and it would lose if it did.
Atmosphere builds on them. It's a real-time, streaming agent runtime that wraps those frameworks behind one annotation-driven endpoint and one SPI. If you already use Spring AI or LangChain4j, you keep it — Atmosphere gives it a transport and a uniform agent surface, and lets you swap the runtime with one dependency. The differentiation isn't the model integration; it's what Atmosphere adds on top — the real-time transport, a governance layer, and a plan verifier.
And the abstraction doesn't trap you below a framework's own features. When
you need something only one of them has — a Spring AI advisor chain, a RAG
QuestionAnswerAdvisor, a VectorStore — you bind your
own fully-configured Spring AI ChatClient with
SpringAiAgentRuntime.setChatClient(...), and Atmosphere runs
that client through the same agent pipeline; it
keeps the defaultAdvisors(...) you wired in, and you can attach
more per request. Or step outside the runtime for a slice of work and call the
framework's primitives directly — the rag-chat sample indexes into a real Spring AI
SimpleVectorStore when an embedding model is configured, though its
default retrieval path uses the built-in word-overlap retriever. You get the portability when you
want it and the framework's own features when you don't.
Governance in the box
Governance is the part I care about most. Atmosphere ships a composable governance layer that runs at the agent's entry point, before the model is called. You compose a policy-admission chain — allow/deny lists, authorization, rate-limit, concurrency-limit, message-length, time-window, a kill switch — and a denied request is rejected and recorded to a decision audit log. It's opt-in: out of the box the gateway is permissive and logs a warning on the first call telling you to install an enforcing one for production, so you turn governance on rather than discovering it was off.
Scope confinement rides the same layer: you pin an agent to a declared purpose,
and an off-topic or prompt-injection-style "ignore your instructions" request is
turned away before it reaches the LLM — by rule-based keywords, embedding-similarity
to the purpose, semantic-intent matching, or an LLM classifier, whichever tier you pick. An @Agent
declares that boundary in its skill file's ## Guardrails; if you're
writing the lower-level @AiEndpoint or @Coordinator
directly, it's an explicit @AgentScope(purpose = …). The default
politely redirects rather than
answering off-scope.
The same machinery includes dedicated prompt-injection classifiers (rule-based, embedding, and LLM-backed), two on-by-default screens — one on the RAG read path that drops poisoned retrieved documents, one on the write path that checks what gets saved to long-term memory — and an OWASP-agentic-threats matrix the admin Console surfaces alongside the live policy decisions. Because it lives in the pipeline, it applies no matter which of the twelve runtimes is underneath.
You write those policies programmatically, or — if you'd rather keep them
declarative — in AWS Cedar or OPA Rego via
adapters over the engines' own CLIs (ai-policy-cedar,
ai-policy-rego; you install the cedar or
opa binary). And the decision
log isn't only in memory: point it at Kafka or
Postgres (ai-audit-kafka,
ai-audit-postgres) and every admit and deny is persisted for the
audit trail.
Guardians of the Agents: verify the plan before it runs
Governance gates a single request. The verifier goes a step further and checks the agent's whole plan before any tool executes. Atmosphere ships an implementation of Erik Meijer's "Guardians of the Agents" (Communications of the ACM, January 2026): a plan source (an LLM, or a deterministic planner) emits a structured tool-call plan up front, with symbolic references standing in for data that only exists at runtime, and a pluggable chain of checks proves that plan safe against a declarative policy before execution begins. A verified plan never executes an unverified tool call.
The chain runs allowlist, well-formedness, capability, taint, and sequencing
checks, and — with the SMT module on the classpath — proves numeric invariants
(for example, "a transfer can never exceed the fetched balance") with an
in-JVM solver, SMTInterpol by default or native Z3 if
you have it. Taint tracking is the one that matters most: a plan that routes
attacker-controlled tool output into a forbidden parameter is rejected
statically, catching a class of prompt-injection a step-by-step ReAct
loop would happily run. It's opt-in — you wire PlanAndVerify into
your agent application, as a couple of the samples do — and the Console's
Validation tab shows the verdicts on demand.
State that outlives the request
Agents aren't always stateless request/response. When a connection drops or a
process restarts, the session isn't lost — a durable session token re-attaches
the client to its rooms and broadcasters, persisted through the pluggable
SessionStore SPI (in-memory, SQLite, and Redis backends ship
in-tree). Separately, a paused agent's whole conversation can be snapshotted and
resumed — the PASSIVATION capability — through the pluggable
CheckpointStore SPI (in-memory, SQLite, and Postgres ship in-tree).
From a checkpoint you can inspect a run, fork it, or hold it at a human-approval
gate before it continues — list, show, fork,
and approve are right there in the CLI.
For long-running work there's a durable Interaction resource:
POST an agent turn to /api/interactions, get an id back,
and the run keeps a bounded step log that survives a disconnect — so a client can
fire a request in the background, drop off, and reconnect later to replay every
step or continue the conversation from where it left off, over REST or
a live socket. And when one node isn't enough, a Redis or Kafka broadcast
backplane relays an agent's streams across the cluster, so a session opened on one
JVM is reachable from any of them.
Workflows, now portable to Temporal
For work that has to survive across steps — not just across a dropped
connection — Atmosphere ships a small durable-workflow primitive:
an ordered list of named steps over an application-owned state type,
checkpointed after every step through the same CheckpointStore SPI.
A step that needs a human returns hibernate(state); the call
returns, and a later run() resumes at the step after the last
completed one — even in a different JVM when the store is persistent.
That in-tree engine is deliberately boring: a loop, a retry budget per step, a
snapshot per step. But plenty of teams already run a durable-execution
platform — Temporal most often — and asking them to stand up a second engine
next to it is a hard sell. So as of 4.0.63 the engine behind
Workflow.run() is a seam: every call resolves a
DurableExecutionProvider through ServiceLoader, the
in-tree engine is the always-on fallback, and an external adapter takes over
when one is registered and reachable. The first such adapter,
atmosphere-checkpoint-temporal, is one dependency — the workflow
code does not change:
<!-- The Workflow<S> code above does not change. This line is the whole migration: -->
<dependency>
<groupId>org.atmosphere</groupId>
<artifactId>atmosphere-checkpoint-temporal</artifactId>
</dependency>
With the adapter on the classpath, Temporal owns what Temporal is good at —
per-step retries, timeouts, and an execution history visible in the Temporal
Web UI. Atmosphere keeps what it promised: the CheckpointStore
snapshot trail, hibernation, and cross-restart resume. Your steps execute as
Temporal activities in the JVM that called run(), against
the live step lambdas, so application state never crosses the Temporal payload
boundary — adding the adapter imposes no serialization constraints on your
state type. A test pins the two engines' snapshot trails as equivalent — the
same ordered sequence of (last-completed-step, done) entries — so both read
the same checkpoint contract.
Beyond chat
The endpoint model scales up from there. You already saw an @AiTool
method in the first example — each one becomes a function the model can call
mid-conversation, and a tool you mark @RequiresApproval — like the
refund above — is routed through a human-in-the-loop approval gate before it runs.
responseAs = SomeRecord.class turns free text into a typed
Java object — and nine of the twelve runtimes now enforce that schema
natively, threading it into the model API's own structured-output
field (OpenAI response_format, Anthropic output_config,
and so on), with a graceful fallback to system-prompt schema instructions when
one can't.
@Coordinator and @Fleet compose multiple agents. And
because agents rarely live alone, Atmosphere speaks the open interop
protocols. It's an MCP server and an MCP client. It talks
A2A (Agent-to-Agent, v1.0.0) — an Atmosphere agent publishes
an AgentCard — optionally Ed25519-signed — and can call other A2A
agents over JSON-RPC.
And it exposes agents over AG-UI, streaming the run lifecycle
(RunStarted, RunFinished, RunError) to a
standard frontend at /atmosphere/agent/{name}/agui.
And there's more in the agent surface than a chat turn: it can route across models by cost or latency, hand a conversation off between agents, keep long-term memory across sessions, take vision and audio input, and run model-generated code in a Docker-backed sandbox when a tool needs to execute something untrusted.
For retrieval, there's a pluggable RAG layer with a
ContextProvider SPI and six backends out of the box — an in-memory
store for development, plus pgvector, Qdrant,
Pinecone, and bridges to a Spring AI VectorStore or
a LangChain4j embedding store — so the same agent can pull grounded context from
whichever vector database you already run. Retrieved documents are screened by
the poisoned-RAG safety filter from the governance section — on by default and
fail-closed (it drops a flagged document rather than letting it through) — before
they reach the model.
All of it is visible in the bundled admin Console — a Vue app the Spring Boot
starter serves at /atmosphere/console/ with no extra setup. It has
a live chat tab plus operational tabs that appear as you wire the matching
pieces in: the governance policy decisions as they happen, the
OWASP-agentic-threats matrix, the plan-verifier's verdicts, and the MCP
Apps host. It's the same UI the samples run on — about thirty of them,
mostly Spring Boot and Quarkus, with a few plain embedded-server and gRPC builds.
Reachable from anywhere
An agent is only useful if clients can reach it. For the browser and mobile
there's atmosphere.js, a TypeScript client that does much more
than open a socket. It speaks WebSocket, SSE, long-polling, chunked streaming, or
WebTransport/HTTP3, and falls back to a configured secondary transport if the
primary one fails. When the network drops, an offline queue
holds outbound messages and drains them on reconnect, while
history sync replays exactly the messages you missed using a
persisted sinceId cursor. There's a rooms-and-presence
API, optimistic updates so a sent message renders instantly and
reconciles when the server confirms, and a connection-status state machine you can
bind to a badge.
All of that ships as idiomatic bindings for React, React Native, Vue, and
Svelte (hooks, composables, and stores — useChat,
useStreaming, useRoom, useOfflineQueue, and
the rest), plus a set of pre-built chat UI components if you don't want to draw the
bubbles yourself. And the agent doesn't have to live in a browser at all: a built-in
channels bridge connects an agent to Telegram,
Messenger, Slack, WhatsApp, and
Discord over webhooks (Discord over the Gateway WebSocket API). Write the agent once; reach it from a
web app, a phone, or a Telegram chat.
And if the client is itself a JVM service, wasync is the Java counterpart to atmosphere.js — a server-to-server client with an ordered WebSocket → SSE → long-polling fallback chain, with gRPC available as a separate bidirectional transport.
Bring an agent you already have: OpenClaw-compatible
An agent's persona doesn't have to be written in Java. Atmosphere treats an
agent as an artifact — a directory of Markdown — that a small Java
@Agent host loads, reading the
OpenClaw canonical workspace layout directly:
AGENTS.md, SOUL.md, USER.md,
IDENTITY.md, and the skills/ tree. Point Atmosphere at an
OpenClaw workspace authored
without any Atmosphere extensions and it runs as-is — no conversion step. That's
the whole compatibility promise: the adapter is discovered through an
AgentWorkspace SPI, so the layout is recognized and loaded, not
rewritten.
When you want Atmosphere's extras, you colocate files the OpenClaw spec
ignores — CHANNELS.md, MCP.md, PERMISSIONS.md,
SKILLS.md, and RUNTIME.md. Atmosphere reads them and
surfaces their contents on the loaded agent definition for downstream primitives
to pick up. Your agent stays portable; Atmosphere just reads more of the directory.
Skills: an agent you can share
The same artifact thinking shows up one level down, at the level of a single
agent's personality and tools. A skill is a
SKILL.md file — YAML frontmatter (name, description, and metadata)
followed by Markdown that becomes the agent's system prompt, with
## Tools, ## Guardrails, and ## Channels
sections — ## Tools is cross-referenced against your @AiTool
methods, while guardrails and channels feed policy and routing. You point an agent at one
with @Agent(skillFile = "skill:research-agent") and that file
is the agent.
Skills resolve from the classpath first — a bundled
atmosphere-skills artifact ships curated skills offline — and the
CLI can also fetch trusted skill files or registry entries from the separate
atmosphere-skills repository. That gives you a workflow: browse with
atmosphere skills list, scaffold a project from one with
atmosphere import <id>, or wire several into a multi-agent app
with atmosphere compose. Remote imports are restricted to trusted
sources unless you pass --trust. An agent stops being code you write
and becomes a file you can pull, version, and share.
Runtime truth, not classpath truth
One principle shaped the whole design: a runtime only advertises a capability
it actually performs. Each adapter's capability set — drawn from a vocabulary of
twenty-two — is pinned by a contract test, so an adapter's capabilities() can't
silently drift from what it declares, and the built-in runtime's native
paths are checked behaviorally. When I added native structured
output, the three runtimes whose adapter path doesn't thread a schema into the
provider call simply don't declare it. If
you've been burned by frameworks that promise more than they deliver,
that's the part I cared about most.
Try it
Getting started is one dependency. Add
atmosphere-ai-spring-boot-starter and a single
@Agent-annotated class is a running, streaming chat app — it
pulls the transport, the AI pipeline, and the agent layer, and auto-configures
the framework and the Console. (Transport only, no AI? Use the lean
atmosphere-spring-boot-starter.)
Prefer to scaffold? The CLI does it in one line —
atmosphere new my-app --template ai-chat, then
./mvnw spring-boot:run. The CLI exposes fourteen starters —
chat, a one-dependency agent, AI chat, tools, RAG, MCP, multi-agent, classroom,
governance, coding, guarded-agent, assistant, browser-agent, and a dentist agent —
all sparse-cloned from the real sample applications. Kotlin is a first-class option too — there's a small DSL and
coroutine extensions — and for an enterprise console you can pull a single
atmosphere-admin-bundle that wires the runtime, AI, coordinator,
RAG, checkpoints, and durable sessions together.
Deploying in a container is first-class too. The atmosphere compose
scaffold emits a Dockerfile (a slim eclipse-temurin:21-jre-alpine
base, EXPOSE 8080) and a docker-compose.yml alongside the
project, so your agent ships as an image with no extra setup — and one of the
samples ships a Jaeger compose file you bring up alongside the app (run with
./mvnw spring-boot:run) to show the OpenTelemetry
traces. (Worth not confusing with the Docker sandbox from earlier: that
one runs the model's own generated code in a throwaway container; this
is just packaging the Atmosphere app itself.)
Atmosphere is Apache 2.0, JDK 21, and the AI layer is additive — it doesn't disturb the transport you may already run in production. I'm not selling you a model wrapper. If the streaming-first framing is wrong for your use case, I'd genuinely like to hear why.
— JFA