OpenClaw is a canonical, filesystem-as-source-of-truth workspace convention for AI agents: you author a directory of Markdown files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md, MEMORY.md, memory/YYYY-MM-DD.md, skills/*) and the runtime boots an agent directly from that directory. No YAML manifest, no code, no custom DSL.
The design goal locked in docs/foundation-primitives.md and CHANGELOG.md: an OpenClaw workspace authored without any Atmosphere extensions must run on Atmosphere without conversion. That zero-config promise is the reason the bridge exists. The alternative — "bring your OpenClaw agent, first translate it to an Atmosphere YAML" — would make Atmosphere yet another proprietary format and kill portability.
modules/ai/src/main/java/org/atmosphere/ai/workspace/
AgentWorkspace.java ← SPI (interface)
AgentDefinition.java ← record returned by the SPI
AgentWorkspaceLoader.java ← ServiceLoader-based dispatcher
OpenClawWorkspaceAdapter.java ← the bridge itself
AtmosphereNativeWorkspaceAdapter.java ← fallback when there's no convention
package-info.java
META-INF/services/org.atmosphere.ai.workspace.AgentWorkspace
(registers OpenClawWorkspaceAdapter + AtmosphereNativeWorkspaceAdapter)
Tests: AgentWorkspaceLoaderTest, OpenClawCompatTest, plus a real-shape fixture at modules/ai/src/test/resources/fixtures/openclaw/ that mirrors the canonical layout.
Consumer: modules/agent/src/main/java/org/atmosphere/agent/processor/AgentProcessor.java:392-400 — this is where the AgentProcessor wires an AgentWorkspace into the per-agent injectable scope at startup. The sample samples/spring-boot-personal-assistant/src/main/resources/agent-workspace/ is the demo payload.
AgentWorkspace is a four-method interface (AgentWorkspace.java:53):
boolean supports(Path workspaceRoot); // claim-check
AgentDefinition load(Path workspaceRoot); // parse
String name(); // stable id, for logging/admin
default int priority() { return 100; } // lower runs firstThe bridge owes the loader nothing beyond those four methods. Third parties (Claude Code, Cursor, a bespoke convention) register their own adapter through META-INF/services/ and the loader picks it up without any code change in core.
Output type AgentDefinition is a record that surfaces the raw bootstrap files both individually (so an admin UI can render or edit each section separately) and composed as a single systemPrompt ready to prefix every conversation turn. It also carries:
skillPaths— absolute paths to discoveredSKILL.mdfiles in precedence orderatmosphereExtensions— aMap<String, String>of Atmosphere-only extension file names → raw Markdown, for downstream primitives to parse according to their own schemas
supports() returns true iff the directory contains an AGENTS.md at its root. That's the minimum OpenClaw marker; everything else is best-effort.
return Files.exists(workspaceRoot.resolve("AGENTS.md"));No partial match, no heuristic. One file decides. A directory without AGENTS.md gets declined — the native fallback catches it.
The adapter normalizes the root to absolute+normalized, then reads these four canonical files into the AgentDefinition:
| File | Semantic slot | Required? |
|---|---|---|
AGENTS.md |
operating rules | yes (via supports) |
SOUL.md |
persona | no |
USER.md |
user profile | no |
IDENTITY.md |
agent identity | no |
Missing files become empty strings — no errors, no placeholders.
composeSystemPrompt() stitches the non-blank sections into a single Markdown block:
## Identity
<IDENTITY.md body>
## Persona
<SOUL.md body>
## User
<USER.md body>
## Operating rules
<AGENTS.md body>
Order is fixed. The pipeline injects this whole string as the system message on every conversation turn — that's the mechanical contract.
Five filenames are read separately and stashed in atmosphereExtensions:
ATMO_EXTENSIONS = List.of(
"CHANNELS.md", "MCP.md", "RUNTIME.md",
"PERMISSIONS.md", "SKILLS.md");These are not part of OpenClaw. OpenClaw-native tooling ignores them. Atmosphere reads them and hands them to downstream primitives (CHANNELS.md → ChannelRegistry, MCP.md → ToolExtensibilityPoint, PERMISSIONS.md → AgentIdentity, etc.). This is the "extend without forking" story: drop these files into any OpenClaw workspace; OpenClaw still runs it, Atmosphere picks up the extras.
Critically, MEMORY.md is not surfaced through atmosphereExtensions even though OpenClaw uses it — memory is read by FileSystemAgentState at a different layer. The compat test asserts this explicitly (OpenClawCompatTest.java:92-93). Preventing memory from bleeding into the extension map keeps concerns separated.
discoverSkills() walks two directories one level deep:
<workspace>/skills/<skill-name>/SKILL.md
<workspace>/.agents/skills/<skill-name>/SKILL.md
Workspace skills come first, .agents/skills second — precedence order is documented in AgentDefinition.skillPaths.
inferName() prefers an explicit name: marker at the top of IDENTITY.md so the workspace directory can be renamed without changing the agent's identity:
# IDENTITY.md
name: pierre
vibe: focused, minimalistFalls back to the directory name if no name: marker is present (AgentWorkspaceLoaderTest.identityNameFallsBackToDirectoryName pins that behavior).
AgentWorkspaceLoader:
- Discovers adapters via
ServiceLoader.load(AgentWorkspace.class). - Defensively adds
OpenClawWorkspaceAdapter+AtmosphereNativeWorkspaceAdapterif either is missing (defense against shaded deployments that dropMETA-INF/services/). - Sorts by
priority(): OpenClaw = 10, Native =Integer.MAX_VALUE. - At
load()time, polls adapters in priority order, picks the first whosesupports()says yes.
Result:
AGENTS.mdpresent → OpenClaw wins (priority 10).- No
AGENTS.md→ falls through to native (AtmosphereNativeWorkspaceAdapter), which accepts any directory and readsREADME.mdas operating rules if present. - No adapter in the list →
IllegalStateExceptionwith a clear message.
loaderPicksOpenClawOverNative and loaderFallsBackToNativeWhenNoSpecificMatch tests lock the selection algorithm.
AgentProcessor.buildFoundationPrimitives() (modules/agent/src/main/java/org/atmosphere/agent/processor/AgentProcessor.java:392) is the one consumer today:
var loader = new org.atmosphere.ai.workspace.AgentWorkspaceLoader();
var adapters = loader.adapters();
if (!adapters.isEmpty()) {
injectables.put(AgentWorkspace.class, adapters.get(0));
}The workspace root itself is resolved (same method, lines 363-372) from:
-Datmosphere.workspace.root=<path>system propertyATMOSPHERE_WORKSPACE_ROOTenv var- Default:
~/.atmosphere/workspace/agents/<agentName>/
Same shape as OpenClaw's convention by design. Tests override with the system property.
Injected into the per-agent scope means any @AiTool method, @Prompt handler, or coordinator step can declare AgentWorkspace as a parameter and receive the live adapter without a ThreadLocal shim — this is Atmosphere's standard injection model.
OpenClawCompatTest.openClawWorkspaceLoadsIdenticallyOnAtmosphere is the closest thing we have to an end-to-end portability assertion. It:
- Copies a real OpenClaw-shaped fixture (
fixtures/openclaw/) withAGENTS.md,SOUL.md,USER.md,IDENTITY.md(withname: pierre),MEMORY.md,memory/2026-04-15.md,skills/example-skill/SKILL.md, plusCHANNELS.md+MCP.mdAtmosphere extensions. - Loads via the real
AgentWorkspaceLoader+OpenClawWorkspaceAdapter. - Asserts the OpenClaw adapter wins (
def.adapterName() == "openclaw"), name comes fromIDENTITY.md, all four sections show up in the composed prompt, the two extension files appear inatmosphereExtensions,MEMORY.mdandAGENTS.mddo not appear there, and theexample-skillis discovered.
The companion openClawDirectoryWithoutAgentsMdIsRejected asserts the negative: a directory with SOUL.md but no AGENTS.md must NOT be claimed by the OpenClaw adapter — otherwise the fallback would never get its turn on ambiguous directories.
- One workspace per load call. No multi-workspace composition in the adapter itself. If you need that, stack
AgentWorkspaceLoadercalls and mergeAgentDefinitions at the call site. - No write-back. Adapters are read-only.
FileSystemAgentStateis the thing that writes (memory promotions, session JSONL). Keeping writes out of the adapter keeps the SPI small and testable. - Extensions are raw Markdown. The bridge does NOT parse
CHANNELS.md/MCP.md/etc. — it hands the raw bytes to downstream primitives, which have their own schemas. That's intentional: one parser per concern, not a mega-parser. - Classpath-only registration at the moment. The loader is
new AgentWorkspaceLoader()per-boot; there's no admin hot-reload endpoint yet. If you want a new adapter live, you restart. .agents/skills/walk is one level deep. Nested skill trees (skills/team-a/coder/SKILL.md) aren't discovered today. That's a followup if we want it.
The alternatives we rejected are documented in docs/foundation-primitives.md:159:
- Custom Atmosphere YAML manifest — the OpenClaw workspace IS the manifest; another format would have been a portability downgrade.
- Annotation-only agents (
@Agent("my-pa") class PrimaryAssistant {}) — that exists (@AiEndpoint,@Agent) but ties identity/persona/rules to Java source; the workspace bridge lets non-developers author agents. - Database-backed agent store — violates the ".agent is a directory" invariant that makes
git diffthe primary edit UX.
The bridge is the tightest translation layer that makes OpenClaw + Atmosphere coexist without either side knowing about the other. That's the entire trick.