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