Introduction
Every major cloud now supports Serverless FAAS capabilities like AWS Lambda/Step Functions, Azure Durable Functions, GCP Cloud Functions and Cloudflare Durable Objects where you write a function or a small stateful actor. This allows you to scale it, pay only for what runs but there is a catch, you build on a proprietary runtime, and the runtime’s storage model, invocation model, and IAM rules become part of your application whether you meant them to or not. You cannot easily rewrite it or run it somewhere else. I saw a recent post (Why we’re moving Wire off Cloudflare Durable Objects) from Wire, which ran every container on Cloudflare Durable Objects since day one. They wrote why they rebuilt their own data plane instead of staying, which included extra network hops on the hottest path, drift of state, separation of compute from data, rigid placement policies and lack of self-hosting. None of these are reliability complaints, instead they’re architectural ceilings baked into a runtime you don’t own. And the pattern generalizes past Cloudflare:
- AWS Lambda: SAM and LocalStack approximate the runtime locally, but diverge on execution environment, IAM, and VPC behavior.
- Azure Durable Functions: Azurite emulates the storage layer, but the replay-based orchestration engine behaves differently under real concurrent load than it does in the emulator.
- GCP Cloud Functions: the Functions Framework runs locally, but Eventarc, Pub/Sub push, and Cloud Run triggers all need live GCP resources.
- Cloudflare Workers/DO/Agents:
wrangler devsimulates KV with SQLite and alarms with in-process timers, but never replicates the distributed routing that decides which data center actually holds your object.
Every one of these runtimes gives you a great abstraction and takes your operational sovereignty in exchange. This post shows how to keep the abstraction like stateful actors, durable storage, alarms, WASM sandboxing, LLM calls, observability, an event bus, webhooks while running it on infrastructure you control with an open-source framework called PlexSpaces.
PlexSpaces
PlexSpaces is an open-source, polyglot actor framework that gives every actor durable KV storage and alarms, routes messages between actors on one node or across a gRPC mesh, and exposes a host API to Go, TypeScript, Python, and Rust. You write an actor once; the same WASM module deploys to your laptop, a Docker container, Kubernetes, bare metal, or several clouds at once, unchanged. The mental model sits close enough to Cloudflare Durable Objects that migrating existing DO code is mostly mechanical. The key difference: there’s no simulated version to diverge from production, because the production runtime is the development runtime.

Part I: Durable Objects
Cloudflare publishes a short list of rules for writing correct Durable Objects, which are easily mapped to a PlexSpaces constraint, and for most of them the mapping is tighter:
| Cloudflare Rule | How PlexSpaces handles it |
|---|---|
| Don’t coordinate between objects from inside an object: use async messaging | host.send(actorId, op, payload) is the only cross-actor primitive. There is no shared-memory path on the same node. |
| Don’t assume a single instance: globally unique, but workers can race to create one | Actor IDs are content-addressed: {name}//{type}::{ns}@nodeId. The node that owns the ID wins. |
| Store state before returning: in-memory state is lost on eviction | getState()/setState() checkpoints on every handler return. Durable KV is secondary store. Survive eviction and restart. |
| Keep objects small: large objects cause cold-start latency | WASM heap is the actor’s private address space, isolated from the host. State serializes only on checkpoint. |
Use blockConcurrencyWhile() for init | onInit() in TypeScript / @init_handler in Python / Init() in Go runs before the first message and restore persisted state. |
| Alarm fires at-most-once | ReminderFacet persists the alarm timestamp in durable storage and re-queues after restart. |
The one meaningful difference: Cloudflare guarantees globally unique placement. PlexSpaces virtual actors are unique per node or per cluster when pinned with @* (any node) or @nodeId (specific node). PlexSpaces provides an object-registry for managing cross-cluster deduplication.
Durable KV Storage
Cloudflare’s ctx.storage gives you a transactionally consistent store scoped to one object:
// Cloudflare DO — JavaScript
const count = await this.ctx.storage.get("count") ?? 0;
await this.ctx.storage.put("count", count + 1);
// Batch operations (storage.get([keys]) / storage.put(map))
const [history, meta] = await this.ctx.storage.get(["room:history", "room:meta"]);
await this.ctx.storage.put(new Map([["room:history", data], ["room:meta", index]]));PlexSpaces gives you the same guarantee through host.kv. Because each actor processes one message at a time, a plain read-modify-write is safe without extra locking. The migrating_cloudflare_workers TypeScript example restores room history on onInit() using batch KV similar to blockConcurrencyWhile:
// PlexSpaces TypeScript — examples/typescript/apps/migrating_cloudflare_workers/guild_chat_actor.ts
protected override onInit(config: Record<string, unknown>): void {
this.state.room_id = String(config.actor_id ?? "");
// blockConcurrencyWhile() equivalent — batch-fetch persisted state before first message
const keys = [
"room:" + this.state.room_id + ":history",
"room:" + this.state.room_id + ":meta",
];
const values = host.kv.multiGet(keys); // like DO storage.get(["k1","k2"])
const [historyRaw] = values;
if (historyRaw) {
const msgs = JSON.parse(historyRaw);
if (Array.isArray(msgs)) {
this.state.messages = msgs;
this.state.message_seq = msgs[msgs.length - 1]?.seq ?? 0;
}
}
}
private persistHistory(): void {
// Batch write — like DO storage.put(new Map([["k1",v1],["k2",v2]]))
host.kv.multiPut({
["room:" + this.state.room_id + ":history"]: JSON.stringify(this.state.messages),
["room:" + this.state.room_id + ":meta"]: JSON.stringify({
message_seq: this.state.message_seq,
last_updated: host.nowMs(),
}),
});
}# PlexSpaces Python — examples/python/apps/migrating_cloudflare_workers/guild_chat.py
def _load_history(self) -> None:
room_id = self._room_id()
index_raw = host.kv.get(f"room:{room_id}:seq_index")
if index_raw:
seqs = json.loads(index_raw)
keys = [f"room:{room_id}:msg:{seq}" for seq in seqs]
values = host.kv.multi_get(keys) # DO storage.get([k1,k2,...]) equivalent
self.messages = [json.loads(v) for v in values if v]
if self.messages:
self.msg_seq = self.messages[-1]["seq"]
def _persist_history(self) -> None:
room_id = self._room_id()
entries = {}
seqs = []
for msg in self.messages:
entries[f"room:{room_id}:msg:{msg['seq']}"] = json.dumps(msg)
seqs.append(msg["seq"])
entries[f"room:{room_id}:seq_index"] = json.dumps(seqs)
host.kv.multi_put(entries) # DO storage.put({k:v,...}) equivalentPlexSpaces also ships atomic operations that Cloudflare leaves you to build yourself with a coordinator object:
// PlexSpaces TypeScript — from RateLimiterActor in guild_chat_actor.ts
// Atomic distributed counter — equivalent to Cloudflare KV atomic increment
const distributedCount = host.kv.increment(windowKey, 1);
// Compare-and-swap — equivalent to DO transactional storage read-modify-write
const applied = await host.kvCas("lock_key", expectedValue, newValue);
// KV with TTL — equivalent to DO storage.put with metadata expiration
await host.kvPutWithTtl("session_token", token, 3600);The Python RateLimiterActor shows both in context:
# PlexSpaces Python — examples/python/apps/migrating_cloudflare_workers/guild_chat.py
@handler("check")
def check(self, user_id: str = "") -> dict:
# Atomic increment — survives actor restarts, equivalent to Cloudflare KV atomic
host.kv.increment(f"rate:{user_id}:total", 1)
# CAS — idempotent token slot reservation, like DO transactional put
cas_key = f"rate:{user_id}:window"
current_val = host.kv.get(cas_key) or ""
host.kv.cas(cas_key, current_val, str(host.now_ms()))
# Token bucket logic operates on in-memory state (safe: single-threaded actor)
allowed = self.buckets[user_id]["tokens"] > 0
if allowed:
self.buckets[user_id]["tokens"] -= 1
return {"allowed": allowed, "remaining": self.buckets[user_id]["tokens"]}Durable Alarms
A DO schedules one future callback that survives node restarts:
// Cloudflare DO
await this.ctx.storage.setAlarm(Date.now() + 10_000);
async alarm() {
const count = await this.ctx.storage.get("count");
await this.ctx.storage.delete("count");
console.log(`Processing ${count} batched requests`);
}PlexSpaces maps this directly through ReminderFacet, which persists the alarm to durable storage and re-queues it automatically after a restart. The AlarmDemoActor in the guild-chat example demonstrates the full lifecycle like set, query, fire, cancel:
// PlexSpaces TypeScript — examples/typescript/apps/migrating_cloudflare_workers/guild_chat_actor.ts
onEnqueue(_payload: Record<string, unknown>): Record<string, unknown> {
this.state.queued++;
if (this.state.queued === 1) {
// First item — equivalent to: await this.state.storage.setAlarm(Date.now() + 30_000)
host.alarm.set(host.nowMs() + 30_000);
}
return { status: "ok", queued: this.state.queued };
}
// Fires when the scheduled timestamp is reached
// Equivalent to Cloudflare DO: async alarm() { ... }
on__alarm__(_payload: Record<string, unknown>): Record<string, unknown> {
const processed = this.state.queued;
this.state.processed += processed;
this.state.queued = 0;
this.state.total_alarm_fires++;
return { status: "ok", processed };
}
onStatus(_payload: Record<string, unknown>): Record<string, unknown> {
const alarmAt = host.alarm.get(); // DO: await this.state.storage.getAlarm()
return { ...this.state, alarm_at: alarmAt, alarm_set: alarmAt > 0 };
}# PlexSpaces Python — examples/python/apps/migrating_cloudflare_workers/guild_chat.py
@handler("start")
def start(self, delay_ms: int = 30000) -> dict:
# Equivalent to: this.state.storage.setAlarm(Date.now() + delay_ms)
host.alarm.set(host.now_ms() + delay_ms)
return {"status": "ok", "fire_at_ms": host.now_ms() + delay_ms}
@handler("__alarm__")
def on_alarm(self) -> dict:
# Equivalent to Cloudflare DO: async alarm() { ... }
processed = len(self.pending_requests)
self.total_processed += processed
self.pending_requests = []
return {"status": "ok", "processed": processed}
@handler("cancel")
def cancel(self) -> dict:
host.alarm.delete() # DO: this.state.storage.deleteAlarm()
return {"status": "ok", "action": "alarm_cancelled"}Get-or-Create (Virtual Actors)
Cloudflare’s core abstraction is the globally unique object that appears on first access, at the cost of a binding declared in wrangler.toml:
// Cloudflare DO — needs a binding in wrangler.toml
const id = env.CHAT_ROOM.idFromName(roomId);
const room = env.CHAT_ROOM.get(id);
await room.fetch("/send", { method: "POST", body: JSON.stringify(msg) });PlexSpaces virtual actors give you the same behavior with no binding file. The actor ID ({name}//{actorType}::{namespace}@*) encodes both the shard key and the actor class, and the @* suffix lets the runtime place it on whichever node is best; pin it with @node-id for data locality:
// PlexSpaces TypeScript
import { getActorRef } from "@plexspaces/sdk";
const room = getActorRef("ChatRoomActor", roomId, "default");
const reply = await room.ask("send_message", { user_id: userId, content: msg });# PlexSpaces Python
from plexspaces.host import get_actor_ref
room = get_actor_ref("ChatRoomActor", room_id, "default")
reply = room.ask("send_message", {"user_id": user_id, "content": msg}, timeout_ms=5000)The actor spins up on its first message, whether or not it existed before.
Listing Actors by Namespace
Cloudflare exposes a Durable Objects namespace list API for management and observability. PlexSpaces has a direct equivalent defined in its proto-first design. The ListActors RPC in actor_runtime.proto accepts namespace, actor_type, state, and node_id filters and returns paginated results:
// proto/plexspaces/v1/actors/actor_runtime.proto
message ListActorsRequest {
string actor_type = 3;
ActorState state = 4;
string node_id = 5;
// Namespace for tenant isolation — only actors in this namespace are returned
string namespace = 6;
PageRequest page_request = 2;
}
message ListActorsResponse {
repeated Actor actors = 2;
PageResponse page_response = 3;
}From a PlexSpaces client, listing all active ChatRoomActor instances in the default namespace:
# HTTP API (equivalent to Cloudflare's list-objects endpoint) curl "http://localhost:8080/api/v1/actors/default/ChatRoomActor?state=active"
// Go SDK
actors, err := client.ListActors(ctx, &ListActorsRequest{
ActorType: "ChatRoomActor",
Namespace: "default",
State: ActorStateActive,
})Cloudflare limits listing to metadata (ID, location, storage size). PlexSpaces returns full Actor records including state, facets, node assignment, resource usage, and tenant/namespace tags.
WebSocket Handling
Cloudflare’s Model
Cloudflare gives the Durable Object two WebSocket modes. In the standard model, the object holds the socket directly:
// Cloudflare DO — standard WebSocket
async fetch(request) {
const [client, server] = Object.values(new WebSocketPair());
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws, message) {
for (const peer of this.ctx.getWebSockets()) {
peer.send(`broadcast: ${message}`);
}
}
async webSocketClose(ws, code) {
ws.close(code, "connection closed");
}The WebSocket Hibernation API (state.acceptWebSocket / getWebSockets()) is Cloudflare’s optimization for objects that hold many sockets but are mostly idle: the object is evicted when no message is being processed, and WebSocket state is restored from durable storage on the next message.
PlexSpaces: A Cleaner Split
PlexSpaces takes a different approach: room state and connection state are in separate actors. A ChatRoomActor holds only membership and message history; each browser connection is a thin-node client registered under its own actor ID. When the room fans out, host.send(actorId, "chat_message", event) routes each delivery through the WsActorTransportClient to the right WebSocket session like the room never holds a socket handle.
This is the same split Discord uses internally in its Elixir stack, where session processes are separate from guild processes. Here’s the real onSend handler from examples/typescript/apps/ws_chat_room/:
// PlexSpaces TypeScript — examples/typescript/apps/ws_chat_room/chat_server_actor.ts
onSend(payload: SendPayload): unknown {
const senderUsername = this.state.members[payload.sender_actor_id] ?? payload.sender_actor_id;
const ts = host.nowMs();
this.state.history.push({
senderActorId: payload.sender_actor_id,
sender: senderUsername,
text: payload.text,
ts,
});
if (this.state.history.length > MAX_HISTORY) {
this.state.history = this.state.history.slice(-MAX_HISTORY);
}
const event = {
sender: payload.sender_actor_id,
sender_username: senderUsername,
text: payload.text,
room_id: this.state.roomId,
ts,
};
// Fan out to all members including sender (delivery confirmation)
// host.send() routes each tell through WsActorTransportClient ? WsRegistry ? thin-node WS session
const memberIds = Object.keys(this.state.members);
for (const actorId of memberIds) {
host.send(actorId, "chat_message", event);
}
return { success: true, members_notified: memberIds.length };
}A companion PresenceActor in the same file tracks online/offline state using a durable reminder (host.sendAfter) to mark a user offline after 55 seconds of silence without external cron job involved:
// PlexSpaces TypeScript — examples/typescript/apps/ws_chat_room/chat_server_actor.ts
onOnline(_payload: OnlinePayload): unknown {
this.state.online = true;
this.state.last_seen = host.nowMs();
host.kv.putJson(`presence:${this.state.userId}`, { online: true, last_seen: this.state.last_seen });
host.sendAfter(60_000, "timeout_check", {}); // durable reminder, survives restart
return { success: true, online: true };
}
onTimeout_check(): unknown {
const idleSince = host.nowMs() - this.state.last_seen;
if (idleSince > 55_000) {
this.state.online = false;
host.kv.putJson(`presence:${this.state.userId}`, {
online: false, last_seen: this.state.last_seen,
});
}
return { checked: true, idle_ms: idleSince };
}Gap Analysis: WebSocket Hibernation
| Feature | Cloudflare DO | PlexSpaces |
|---|---|---|
| Accept WebSocket in object | ctx.acceptWebSocket(ws) | Thin-node client; actor never holds socket |
| Per-socket tags | ctx.acceptWebSocket(ws, tags) | Actor ID is the tag – lookup by actor ID |
| Get sockets by tag | ctx.getWebSockets(tag) | host.send(actorId, ...) routes directly |
| Evict during idle | Hibernation API (cost optimization) | Actor checkpoints and can be evicted; reconnect restores via getState() |
| Socket-level error handling | webSocketError(ws, err) | Session actor handles disconnect |
| Outgoing WebSocket from DO | new WebSocket(url) in fetch | host.httpClient("link").fetch(...) or service link for outbound calls |
The PlexSpaces model costs more code upfront (session actor + room actor) and pays you back with independent scaling: fan-out scales with the number of receivers, not with the room actor’s memory; a crashed session doesn’t lock the room; reconnect logic is client-side only.
Fan-Out: DO’s Missing host.send() Primitive
In Cloudflare, cross-object fan-out means individual fetch() calls or Queues, which are not lean as a fire-and-forget tell. PlexSpaces’s host.send() is a fire-and-forget message routed through the actor mesh, no HTTP overhead, with in-process delivery for co-located actors. The Python guild-chat send_message handler shows this clearly:
# PlexSpaces Python — examples/python/apps/migrating_cloudflare_workers/guild_chat.py
@handler("send_message")
def send_message(self, user_id: str = "", content: str = "") -> dict:
msg = self._add_message(user_id, content, host.now_ms())
# Fan-out: fire-and-forget to each member actor
# Mirrors Discord's Manifold pattern for distributed fan-out
# In Cloudflare DO, this would be individual fetch() calls — expensive
fan_out_count = 0
for member_id in list(self.members.keys()):
if member_id != user_id:
host.send(member_id, "receive_message", {
"room_id": self._room_id(),
"seq": msg["seq"],
"from": user_id,
"content": content,
})
fan_out_count += 1
self._persist_history() # batch multiPut — one KV call for the whole room
return {"status": "ok", "seq": msg["seq"], "fan_out": fan_out_count}Part II: Cloudflare Agents SDK
Cloudflare’s Agents SDK builds stateful AI agents on top of Durable Objects. PlexSpaces covers the same patterns; some are direct translations and a few require a different shape.
Conversation State and Memory
Cloudflare stores conversation history in the DO’s storage. PlexSpaces does the same through host.kv, with the same durability guarantee: the ChatAgentActor in examples/python/apps/chat_agent/ stores history under a well-known key and restores it across activations:
# PlexSpaces Python — examples/python/apps/chat_agent/chat_agent.py
@handler("chat")
def chat(self, message: str = "") -> dict:
# Load history — equivalent to: await this.storage.get('history')
history = host.kv.get_json("history") or []
history.append({"role": "user", "content": message, "timestamp": host.now_ms()})
assistant_reply = self._call_llm(history)
history.append({"role": "assistant", "content": assistant_reply, "timestamp": host.now_ms()})
# Persist — equivalent to: await this.storage.put('history', history)
host.kv.put_json("history", history)
self.total_messages += 1
# Schedule summarization alarm once history is long enough
if len(history) > _ALARM_THRESHOLD and host.alarm.get() == 0:
host.alarm.set(host.now_ms() + _ALARM_DELAY_MS)
return {"status": "ok", "reply": assistant_reply, "history_length": len(history)}
@handler("__alarm__")
def on_alarm(self) -> dict:
# Durable alarm callback — equivalent to Cloudflare Agents SDK onAlarm()
history = host.kv.get_json("history") or []
summary = self._call_llm([{
"role": "user",
"content": f"Summarize this conversation (2-3 sentences): {json.dumps(history)}"
}])
host.kv.put("summary", summary)
host.kv.delete("history") # clear after summarizing
return {"status": "ok", "action": "summarized", "messages_summarized": len(history)}For long-term cross-session memory, store summaries under a user-scoped key (memory:{user_id}) and inject them into the next conversation’s system prompt similar to Cloudflare’s getMemory/setMemory implementation.
Calling LLMs
Cloudflare’s Agents SDK routes every call through env.AI, Cloudflare’s own inference gateway, locked to providers they support:
// Cloudflare Agents SDK — locked to Cloudflare's AI gateway
const response = await this.env.AI.run("@cf/meta/llama-3-8b-instruct", { messages });PlexSpaces actors call any provider through a named HTTP service link resolved at deploy time, so the actor code never mentions a specific vendor:
# PlexSpaces Python — examples/python/apps/chat_agent/chat_agent.py
def _call_llm(self, messages):
http = ServiceHttpClient("llm-link")
body = {
"model": "claude-3-5-haiku-20241022",
"max_tokens": 1024,
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
}
resp = http.post("/v1/messages", body)
# Parse Anthropic response
if isinstance(resp, dict):
content = resp.get("content", [])
if content and isinstance(content, list):
return content[0].get("text", "")
return "[LLM unavailable]"app-config.toml points the link at Ollama locally, Anthropic or OpenAI in production, or an internal AI gateway in a regulated environment:
# Local development — Ollama
[[service_links]]
name = "llm-link"
url = "http://localhost:11434"
# Production — swap without touching actor code
[[service_links]]
name = "llm-link"
url = "https://api.anthropic.com"
headers = { "x-api-key" = "${ANTHROPIC_API_KEY}" }TypeScript actors use the same pattern:
// PlexSpaces TypeScript — examples/typescript/apps/chat_agent/
const resp = host.httpClient("llm-link").post("/v1/messages", {
model: "claude-3-5-haiku-20241022",
max_tokens: 1024,
messages,
});Durable Workflows
Cloudflare Agents SDK ships workflow primitives that checkpoint multi-step sequences. PlexSpaces WorkflowActor gives you the same like Run, Signal, and Query RPCs map to start, inject external events, and inspect state. For example, the payment workflow in examples/go/apps/migrating_cadence/payment_workflow.go shows the shape:
// PlexSpaces Go — examples/go/apps/migrating_cadence/payment_workflow.go
// PaymentWorkflow implements WorkflowActor for idempotent payment processing.
// Steps: validate ? authorize (with retry) ? capture ? settle.
// Signals: refund, cancel. Queries: status, payment_id.
type PaymentWorkflow struct {
plexspaces.BaseActor
PaymentID string `json:"payment_id"`
Status string `json:"status"` // pending ? validated ? authorized ? captured ? settled
Steps []PaymentStep `json:"steps"`
RefundRequested bool `json:"refund_requested"`
}
func (p *PaymentWorkflow) Run(payloadJSON string) string {
// Each step checkpoints via getState/setState before proceeding.
// If the node crashes mid-run, the workflow resumes from the last checkpoint.
p.Status = "validating"
if err := p.validatePayment(); err != nil {
p.Status = "failed"
return marshal(map[string]any{"error": err.Error()})
}
p.addStep("validate")
// Authorize with retries (idempotency key prevents double-charge)
p.Status = "authorizing"
for attempt := 0; attempt < 3; attempt++ {
if err := p.authorizePayment(); err == nil {
break
}
}
p.addStep("authorize")
// ... capture, settle
return marshal(map[string]any{"status": p.Status, "payment_id": p.PaymentID})
}
func (p *PaymentWorkflow) Signal(name, _ string) {
switch name {
case "refund":
p.RefundRequested = true
case "cancel":
p.Status = "cancelled"
}
}
func (p *PaymentWorkflow) Query(name, _ string) string {
return marshal(map[string]any{"status": p.Status, "payment_id": p.PaymentID})
}For multi-agent orchestration, OrchestratorActor in examples/go/apps/miniclaw/ decomposes a task into sub-tasks, delegates each to a worker agent discovered via process group, and aggregates results through TupleSpace:
// PlexSpaces Go — examples/go/apps/miniclaw/orchestrator.go
func (o *OrchestratorActor) Run(payloadJSON string) string {
task := stringVal(parsePayload(payloadJSON), "task", "")
taskID := fmt.Sprintf("orch-%d", host.NowMs())
o.Status = "running"
o.TaskID = taskID
// Discover available agents via process group membership
agentID, err := pgFirst("svc:agent")
if err != nil {
return marshal(map[string]any{"error": "no agents in svc:agent process group"})
}
// Decompose and delegate sub-tasks
subTasks := decompose(task)
for i, subTask := range subTasks {
o.Progress = (i + 1) * 100 / len(subTasks)
result, err := host.Ask(agentID, "chat", map[string]any{
"message": subTask,
"session_id": fmt.Sprintf("orch-%s-%d", taskID, i),
}, 30000)
if err != nil {
return marshal(map[string]any{"error": "sub-task failed: " + err.Error()})
}
// Store result in TupleSpace for aggregation
host.TS().Write([]any{"orch_result", taskID, i, result})
}
o.Status = "completed"
return marshal(map[string]any{"task_id": taskID, "status": "completed", "sub_tasks": len(subTasks)})
}Human-in-the-Loop
Cloudflare’s human-in-the-loop pattern pauses an agent on a high-stakes action and waits for external approval before resuming. PlexSpaces implements this natively through a GenFSM actor, e.g., ApprovalGateActor in examples/python/apps/minipi/approval_gate.py:
# PlexSpaces Python — examples/python/apps/minipi/approval_gate.py
@fsm_actor(states=["idle", "awaiting_approval", "approved", "rejected"], initial="idle")
class ApprovalGateActor:
"""
FSM states: idle ? awaiting_approval ? approved / rejected ? idle
Key insight: the agent can wait for days. DurabilityFacet preserves all state
durably — no polling, no timeouts burning tokens.
"""
fsm_state: str = state(default="idle")
pending_request: dict = state(default_factory=dict)
pending_agent_id: str = state(default="")
@handler("request_approval")
def request_approval(self, agent_id: str = "", action: str = "", context: dict = None) -> dict:
"""An agent requests human approval for a high-stakes action."""
if self.fsm_state != "idle":
return {"status": "busy", "current_agent": self.pending_agent_id}
self.fsm_state = "awaiting_approval"
self.pending_agent_id = agent_id
self.pending_request = {"action": action, "context": context or {}, "requested_at_ms": host.now_ms()}
# Store request for external review (dashboard, Slack notification, etc.)
host.kv.put(f"approval_request:{self.actor_id}", json.dumps(self.pending_request))
return {"status": "pending", "gate_id": self.actor_id}
@handler("approve")
def approve(self, approver: str = "", comment: str = "") -> dict:
"""Human approves — signals the suspended agent to resume."""
agent_id = self.pending_agent_id
self.fsm_state = "approved"
self.decision_history.append({
"action": self.pending_request.get("action"),
"decision": "approved",
"approver": approver,
"decided_at_ms": host.now_ms(),
})
# Signal the waiting agent to resume with the decision
host.send(agent_id, "workflow_signal:resume", {
"decision": "approved",
"approver": approver,
"comment": comment,
})
self.fsm_state = "idle"
self.pending_agent_id = ""
return {"status": "approved", "agent_id": agent_id}
@handler("reject")
def reject(self, approver: str = "", reason: str = "") -> dict:
"""Human rejects — signals the agent with the rejection."""
agent_id = self.pending_agent_id
host.send(agent_id, "workflow_signal:resume", {
"decision": "rejected",
"approver": approver,
"reason": reason,
})
self.fsm_state = "idle"
return {"status": "rejected", "agent_id": agent_id}The agent on the other side calls host.ask("approval_gate", "request_approval", {...}) then processes the workflow_signal:resume message when it arrives. Because state is checkpointed durably, the agent can wait hours or days with no polling loop and no timeout burning tokens.
Long-Running Agents
Cloudflare’s long-running agent pattern uses alarms to wake a dormant agent on a schedule. PlexSpaces handles this identically, e.g., any actor with the ReminderFacet can schedule work arbitrarily far in the future. The ChatAgentActor summarization alarm is one example; for a true long-running loop:
@actor
class ChatAgentActor:
"""Minimal chat agent: conversation in KV, LLM via service link, alarm for summarization."""
actor_id: str = state(default="")
total_messages: int = state(default=0)
total_summarizations: int = state(default=0)
@init_handler
def on_init(self, config: dict) -> None:
self.actor_id = config.get("actor_id", "")
host.info(f"ChatAgentActor init actor_id={self.actor_id}")
@handler("__alarm__")
def on_alarm(self) -> dict:
"""Durable alarm callback — equivalent to Cloudflare Agents SDK onAlarm().
Summarizes conversation history and stores a summary KV key,
then clears history.
"""
host.info("ChatAgentActor: alarm fired — summarizing history")
history = host.kv.get_json("history") or []
if not history:
return {"status": "ok", "action": "no_history_to_summarize"}
# Summarize via LLM
summary_prompt = (
f"Summarize this conversation concisely (2-3 sentences): "
f"{json.dumps([{'role': m['role'], 'content': m['content']} for m in history])}"
)
summary = self._call_llm([{"role": "user", "content": summary_prompt}])
# Persist summary, clear history — equivalent to: storage.put('summary', s); storage.delete('history')
host.kv.put("summary", summary)
host.kv.delete("history")
self.total_summarizations += 1
host.info(f"ChatAgentActor: summarized {len(history)} messages")
return {
"status": "ok",
"action": "summarized",
"messages_summarized": len(history),
}What PlexSpaces adds beyond Cloudflare’s model: the actor can also be signalled externally at any time via host.send(actorId, "wake_early", {...}) — you’re not limited to the alarm cadence.
Agents Feature Map
| Cloudflare Agents SDK | PlexSpaces |
|---|---|
this.storage.get/put (conversation history) | host.kv.get_json / host.kv.put_json |
env.AI.run(model, messages) | ServiceHttpClient("llm-link").post(...) |
storage.setAlarm / onAlarm() | host.alarm.set() / @handler("__alarm__") |
connection.send(msg) | host.send(actorId, op, payload) |
| Workflow checkpointing | WorkflowActor Run/Signal/Query + durable state |
| Human-in-the-loop / approval gates | @fsm_actor + workflow_signal:resume |
| Long-running scheduled agents | ReminderFacet + alarm reschedule |
| Multi-agent orchestration | OrchestratorActor + process groups + TupleSpace |
env.AI binding in wrangler.toml | [service_links] in app-config.toml |
| Cloudflare edge only | Local, Docker, K8s, on-prem, multi-cloud |
Part III: Distributed Computation
Cloudflare Workers and Lambda optimize for millisecond, latency-sensitive request handling. PlexSpaces handles a second problem class: large-scale computation across resources that come and go.
ShardGroups: Scatter-Gather Without the Plumbing
For data-parallel and ML-style workloads, PlexSpaces exposes MPI-style collectives directly through the host API:
// Create a pool of 20 workers, hash-partitioned
let pool_id = client.create_worker_pool(
"worker-pool-1", "worker", 20,
PartitionStrategy::Hash, HashMap::new(),
).await?;
// Bulk update: 10,000 messages routed to the right shard by key
client.parallel_update(&pool_id, updates, ConsistencyLevel::Eventual, false).await?;
// Parallel map: query every shard simultaneously
let results = client.parallel_map(&pool_id, json!({ "action": "get_total_count" })).await?;
// Parallel reduce: aggregate stats across all shards
let stats = client.parallel_reduce(
&pool_id, json!({ "action": "stats" }),
ShardGroupAggregationStrategy::Concat, 20,
).await?;Idle Browsers as Compute Nodes
The mersenne_prime TypeScript example runs a GIMPS-style distributed primality search: browser tabs connect as thin WebSocket clients, receive worker JavaScript from a CodeServerActor, and run Lucas-Lehmer tests inside Web Workers. A CoordinatorActor running as WASM on the server assigns exponents, tracks per-worker CPU cores, and dispatches the next candidate immediately on each result:
// PlexSpaces TypeScript — examples/typescript/apps/mersenne_prime/mersenne_actor.ts
onResult(payload: ResultPayload): unknown {
const item = this.state.work[String(payload?.p)];
item.status = 'done';
item.is_prime = Boolean(payload.is_prime);
item.duration_ms = payload.duration_ms ?? 0;
// Record every completed candidate in TupleSpace and bump a Prometheus counter
host.ts.write(['result', String(payload.p), item.is_prime ? 'true' : 'false',
String(item.duration_ms), payload.actor_id ?? 'unknown']);
host.incrCounter('ts-mersenne-prime', item.is_prime ? 'primes_found' : 'composites_found');
// Immediately hand the same worker the next pending candidate
const next = this._nextPending(this.state.workers[payload.actor_id!]?.cpu_cores ?? 1);
if (next) {
next.status = 'assigned';
host.send(payload.actor_id!, 'assign_work', { p: next.p, done: false });
}
return { ok: true };
}Open the same URL in ten browser tabs and you have ten compute shards, coordinated by one WASM actor, with zero additional infrastructure.
Comprehensive Feature Map
| Feature | Cloudflare DO / Agents | PlexSpaces |
|---|---|---|
| Durable KV | ctx.storage.get/put | host.kv.get/put |
| Batch KV write | storage.put(new Map) | host.kv.multiPut(entries) |
| Batch KV read | storage.get([keys]) | host.kv.multiGet(keys) |
| Atomic CAS | Manual retry | host.kv.cas(key, expected, new) |
| Atomic counter | Manual CAS | host.kv.increment(key, delta) |
| KV TTL | putWithMetadata | host.kv.putWithTtl(key, val, secs) |
| Durable alarm | storage.setAlarm(ts) | host.alarm.set(ts) |
| Alarm query | storage.getAlarm() | host.alarm.get() |
| Alarm cancel | storage.deleteAlarm() | host.alarm.delete() |
| Alarm callback | async alarm() | on__alarm__() / @handler("__alarm__") |
| Get-or-create | env.BINDING.get(id) | getActorRef(type, name, ns) |
| List actors by namespace | Cloudflare REST API | ListActors gRPC / HTTP REST |
| Init lifecycle | blockConcurrencyWhile | onInit() / @init_handler / Init() |
| WebSocket (standard) | DO holds socket | Thin-node session actor per connection |
| WebSocket hibernation | state.acceptWebSocket | Room actor eviction + getState() restore |
| LLM calls | env.AI.run(model, msgs) | ServiceHttpClient("llm-link").post(...) |
| Conversation state | this.storage.get('history') | host.kv.get_json("history") |
| Durable workflows | Cloudflare Workflows | WorkflowActor Run/Signal/Query |
| Human-in-the-loop | Manual pause / external call | @fsm_actor + workflow_signal:resume |
| Long-running agents | scheduleAlarm() | ReminderFacet + alarm reschedule |
| Multi-agent orchestration | Multiple DO fetches | Process groups + TupleSpace coordination |
| MCP tool integration | McpAgent class | HTTP service link (client-side) |
| Routing config | wrangler.toml [[bindings]] | app-config.toml [[children]] |
| Cross-actor fan-out | Individual fetch() calls | host.send() (in-process or mesh) |
| Fire-and-forget delay | External queue / DO alarm | host.sendAfter(delayMs, op, payload) |
| Multi-language | TypeScript only | Go, TypeScript, Python, Rust |
| Local dev | wrangler dev (simulated) | Same binary, full parity |
| On-prem / self-host | No | Yes |
| Multi-cloud | No | Yes, via gRPC mesh |
| Observability | Cloudflare Analytics | Prometheus + OTLP, self-hosted |
| Webhooks | Worker fetch() | [[http_routes]] in config |
| Process groups | Not supported | host.pg.broadcast(group, op, payload) |
| Data-parallel compute | Not supported | ShardGroups (scatter-gather, allreduce) |
A couple of things need real rework, not a find-and-replace:
- WebSocket architecture. If your DO holds sockets directly today, plan for a thin node that hosts actors.
- Bindings vs children. Cloudflare bindings live in
wrangler.tomland show up asenvproperties. PlexSpaces declares the same relationships as supervision children inapp-config.toml.
Running Everywhere
The whole point is that development and production run the identical binary:
# Local development — exact production behavior plexspaces-node start --config app-config.toml # Docker — same binary, same config docker run -v $(pwd):/app plexspaces/node start --config /app/app-config.toml # Kubernetes — same config via Helm helm install my-app plexspaces/app-chart \ --set config.path=app-config.toml \ --set persistence.storage=postgres # Multi-cloud — nodes in GCP + AWS joined over a gRPC mesh; actors route transparently
An alarm that fires in production runs through the exact code path you tested on your laptop. There’s no gap to debug between wrangler dev and prod, because there’s only one runtime.
Working Examples
Every example referenced above is in the repository (github.com/bhatti/PlexSpaces):
- Guild chat (DO migration pattern):
examples/{go,typescript,python,rust}/apps/migrating_cloudflare_workers/:ChatRoomActorwith member fan-out, a token-bucketRateLimiterActorbacked byhost.kv.incrementandhost.kv.cas, and anAlarmDemoActormirroring DO’s full alarm lifecycle - WebSocket chat room:
examples/{typescript,python,go,rust}/apps/ws_chat_room/:ChatRoomActorplus aPresenceActorthat uses a durable reminder to detect idle disconnects - AI chat agent (Cloudflare Agents SDK pattern):
examples/{go,typescript,python,rust}/apps/chat_agent/: conversation history in KV, LLM calls through service link, durable summarization alarm - Human-in-the-loop approval gate:
examples/python/apps/minipi/approval_gate.py: FSM-based approval workflow,workflow_signal:resumehandoff, state durable across multi-day waits - Multi-agent orchestration:
examples/go/apps/miniclaw/:OrchestratorActordecomposing tasks, delegating via process groups, aggregating through TupleSpace - Durable payment workflow:
examples/go/apps/migrating_cadence/payment_workflow.go:WorkflowActorwith Run/Signal/Query, idempotent retry, refund and cancel signals - Mersenne prime search (browser compute):
examples/typescript/apps/mersenne_prime/: browser tabs as Lucas-Lehmer worker shards, coordinated by a WASMCoordinatorActor - wasmCloud migration:
examples/python/apps/migrating_wasmcloud/session_store.py: capability-based session store showinghost.kv.list, inter-actor ask, and timer-driven cleanup - Every abstraction in one place:
examples/{go,typescript,python,rust}/apps/abstractions/: durable virtual-actor reactivation, workflow run/signal/query, process-group event delivery, timers, reminders, KV, tuple space, and blob storage
Conclusion
The Cloudflare model is the right model: stateful actors, durable storage, alarms, WebSocket session management, LLM calls baked in. Wire’s post makes the same point from the other direction as they’re not leaving because the model is wrong, they’re leaving because specific architectural ceilings came due at their scale. PlexSpaces keeps the model and removes the ceiling: you write the same actors, get the same alarms and durable storage and observability, and the binary that runs on your laptop is the exact binary that runs in production, on any cloud, on-prem, or across all of them at once. The migration from Cloudflare DO code is mostly mechanical. What you get back is the ability to run anywhere, own your infrastructure and never again debug a divergence between wrangler dev and prod.
GitHub: github.com/bhatti/PlexSpaces
Previous posts in this series:
- Building PlexSpaces: Decades of Distributed Systems Distilled
- Polyglot WebAssembly: Four Languages, One Runtime
- 20+ Production Patterns for Distributed AI Agents Using Actors and TupleSpaces
- Building Mini OpenClaw: Secure AI Agents with Actors, WASM, and Supervision
- Building a Self-Improving AI Agent with Durable Actors: MiniHermes
- Building an Agent Harness and Eval Pipeline with Durable Actors





























