← all diagrams · how mycelia recovers
mycelia, translated: React · Temporal · ECS
mycelia runs long-lived AI agents with Elixir supervisors and GenServers on Kubernetes. This page maps each piece to a stack of React, Temporal (Python SDK) and AWS ECS, where Temporal plays the part of the long-running GenServers and their supervisors.
A sketch, not a description. No team runs mycelia this way today. It shows where each idea would land if you built the same thing on your stack, and where the two models really differ. Tap a row for the details.
mycelia today
your stack
The page
Lustre app (Gleam)the spores page in the browser
≈
React appthe same page
The easiest part: both are Elm-style UIs that render from state. Lustre's
update(model, msg) is a React useReducer.Phoenix Channel + PubSubone WebSocket; updates fan out to every open tab
≈
API service + WebSocket/SSEe.g. FastAPI, with Redis pub/sub for fan-out
A Temporal workflow can't push to a browser. Activities publish each event (to Redis, or a table) and the API streams it to the page. Reads of current state can be a Query on the workflow; prompts from the page become a Signal or Update.
The long-running processes (BEAM ≈ Temporal)
BEAM + top Supervisorthe VM that keeps processes alive
≈
Temporal Server + its databasekeeps every workflow's history; nothing is in memory only
The BEAM keeps processes alive by restarting them. Temporal keeps them alive by recording every step, so a workflow can be rebuilt anywhere by replaying its history. Different mechanism, same promise: the thing you started keeps going.
Spores.Supervisor (DynamicSupervisor)starts one tree per awake spore
≈
client.start_workflow(...)start one workflow per sporeNo supervisor process to write: you ask Temporal to start a workflow, and it guarantees it runs to completion. The Worker fleet (Python processes polling a task queue) is where the code actually executes.
Registryfind a spore's processes by name
≈
Workflow ID
id="spore-<id>": one running per IDTemporal won't run two open workflows with the same ID, so the ID is your name lookup and your "only one of these" lock in one.
client.get_workflow_handle("spore-42") is Registry.lookup.Spores.Tree + Agent (GenServer)holds the conversation, one per spore
≈
SporeWorkflowa long-running "entity" workflow, one per sporeThe GenServer's state becomes the workflow's instance variables.
Gotcha: history grows with every step. Call
handle_cast ≈ @workflow.signal, handle_call ≈ @workflow.update, reading state ≈ @workflow.query. mycelia reloads the chat from Postgres after a crash; Temporal rebuilds the state by replaying history instead.Gotcha: history grows with every step. Call
workflow.continue_as_new() when workflow.info().is_continue_as_new_suggested(), carrying the state forward. Keep big things (the transcript) in your own database, not in workflow state.Host (GenServer)creates, watches, deletes the spore's pod
≈
Activities
ensure_task · stop_task: call the ECS APIWorkflow code can't do I/O; anything that touches the outside world is an Activity.
ensure_task finds the spore's running task or calls RunTask; stop_task calls StopTask. Like the Host, it should look the task up rather than trust memory, so a retry never starts a second one (tag tasks with the spore ID, or use startedBy).Agent ↔ bridge streamreads numbered events from the pod
≈
A heartbeating Activity
send_prompt: streams events, activity.heartbeat(seq)While a turn runs, the activity reads the bridge's numbered events and heartbeats the last number. If the worker dies, the heartbeat stops, Temporal times it out and retries on another worker, which reads
activity.info().heartbeat_details and resumes after that number. That's the same trick as mycelia's Agent reconnecting to the same pod.Supervisor restart + restart limit3 restarts in 5 s, then escalate to the parent
≈
RetryPolicy on each Activitybackoff, maximum_attempts, then the error reaches the workflowWhen an activity runs out of retries, an
Difference: a bug in workflow code doesn't "crash and restart clean". Temporal retries that workflow task until you deploy a fix, then it continues from where it was.
ActivityError is raised inside the workflow, which decides what to do: that's escalation to the parent. Use non_retryable_error_types for "don't bother retrying" (e.g. task definition missing).Difference: a bug in workflow code doesn't "crash and restart clean". Temporal retries that workflow task until you deploy a fix, then it continues from where it was.
Idle timeout: sleep after 30 minHost deletes the pod; home kept
≈
workflow.wait_condition(..., timeout=30 min)on timeout, run stop_task; the workflow itself stays openDurable timers are free in Temporal: a workflow can wait 30 minutes or 30 days without holding a worker. The "asleep" spore is just a workflow waiting for its next Signal.
reattach at boot · handoff on deployrebuild trees for running pods
≈
Nothing to writeworkflows outlive workers by design
This is the biggest thing Temporal gives you: any worker can pick up any workflow, so there's no reattach and no handoff.
The price: workflow code must be deterministic, so changing it while workflows are running needs
The price: workflow code must be deterministic, so changing it while workflows are running needs
workflow.patched("...") or Worker Versioning. mycelia's hot code reloading has no direct equivalent.The machines (Kubernetes ≈ ECS)
Deployment "mycelia" (1 replica)runs the BEAM; kept alive by Kubernetes
≈
ECS ServicesTemporal workers (N tasks), the API, maybe Temporal itself
An ECS Service keeps a desired count of tasks running, like a Deployment. Workers are stateless, so you can run several and deploy them with rolling updates. Temporal itself can be Temporal Cloud, or self-hosted as more ECS services.
spore pod (a bare Pod)created and deleted by the Host
≈
Standalone ECS task (
RunTask)created and stopped by activities, not by a ServiceNot a Service: a Service would restart the task on its own and fight the workflow over who owns it. The workflow is the controller, exactly like mycelia's Host. With
awsvpc networking, each task gets its own private IP for the activity to call its bridge.bridge.py in the podkeeps Claude Code running; numbers its output
≈
The same bridge, as the task's containeror a sidecar container in the task
Unchanged. Whatever runs inside the task still needs an HTTP way in (prompts) and a numbered way out (events), so activities can resume a stream.
home volume (PVC, local-path)survives the pod; tied to one machine
≈
EFS access point per sporemounted into the task
EFS isn't tied to one machine (or one availability zone), so a lost machine is just "start the task somewhere else". That's better than mycelia's local-path volumes in that one case.
Postgresconversations, members, everything
≈
Your database + Temporal'stranscripts in yours; workflow state in Temporal's
Keep the two jobs apart: Temporal's history is for "where is this process up to", not for large data or for queries from the page.
When one side fails
The same failures as the recovery page, and who handles each on both stacks.
| What fails | mycelia (BEAM + Kubernetes) | your stack (Temporal + ECS) |
|---|---|---|
| the conversation process | Agent crashes → its Tree restarts it, it reloads from Postgres and reconnects to the same pod | a worker dies → another worker replays the workflow's history and carries on; the running activity retries after its heartbeat timeout and resumes from the last event number |
| the pod owner | Host crashes → Host and Agent restart (rest_for_one); the Host finds the pod still running | an ensure_task attempt fails → RetryPolicy retries it; it looks the task up first, so no duplicate |
| the whole control plane | mycelia pod dies → the Deployment starts a new one; reattach rebuilds every tree | all workers down → workflows simply pause; when the ECS Service brings workers back, they continue. Temporal Server down → everything pauses, nothing is lost |
| a bug in the process's code | crash → restart in a clean state; past the restart limit, escalate | activity bug: retries, then the workflow decides. Workflow bug: that workflow is stuck until you deploy a fix, then it resumes |
| the agent's container | pod dies → Agent notices, Host creates a new pod on the same home volume | task stops → the activity's call fails (or an EventBridge "task stopped" event becomes a Signal) → the workflow runs ensure_task again with the same EFS access point |
| the machine under it | pod lost; its local-path volume probably waits for the machine | task lost; EFS is reachable from anywhere, so a new task starts elsewhere right away |
What you'd gain, and what you'd give up
Temporal gives you
- No reattach or handoff code: workflows outlive any worker.
- Durable timers (sleep after 30 idle minutes, wake days later).
- A full history of every spore, visible in Temporal's web UI.
- Retries with backoff declared, not written.
The BEAM gives mycelia
- Restarts in microseconds, in memory, with no database round trip per step.
- Hot code reloading, without determinism rules.
- Millions of cheap processes in one VM; no history to keep small.
- The WebSocket and the process in the same runtime.
What SporeWorkflow could look like
A sketch in Temporal's Python SDK, trimmed. The comments point at the mycelia piece each part replaces.
# ≈ Spores.Tree + Agent + Host: one workflow per spore, id="spore-<id>" @workflow.defn class SporeWorkflow: def __init__(self) -> None: self.inbox: list[str] = [] self.task: TaskRef | None = None # ≈ the Host's pod; None while asleep @workflow.signal # ≈ GenServer.cast: a prompt from the page def prompt(self, text: str) -> None: self.inbox.append(text) @workflow.query # ≈ reading the GenServer's state def status(self) -> str: return "awake" if self.task else "asleep" @workflow.run async def run(self, spore_id: str, inbox: list[str]) -> None: self.inbox = inbox while True: try: # ≈ sleep after 30 idle minutes await workflow.wait_condition(lambda: bool(self.inbox), timeout=None if self.task is None else timedelta(minutes=30)) except asyncio.TimeoutError: await workflow.execute_activity(stop_task, self.task, start_to_close_timeout=timedelta(minutes=1)) self.task = None continue # ≈ Host: find the running task or start one (RunTask, EFS home for this spore) self.task = await workflow.execute_activity(ensure_task, spore_id, start_to_close_timeout=timedelta(minutes=5), retry_policy=RetryPolicy(maximum_attempts=5)) # ≈ restart limit # ≈ Agent ↔ bridge: stream the turn, heartbeating the last event number try: await workflow.execute_activity(send_prompt, args=[self.task, self.inbox[0]], start_to_close_timeout=timedelta(hours=1), heartbeat_timeout=timedelta(seconds=30)) self.inbox.pop(0) except ActivityError: self.task = None # ≈ "pod gone": ensure_task starts a new one next loop if workflow.info().is_continue_as_new_suggested(): workflow.continue_as_new(args=[spore_id, self.inbox]) # keep history small @activity.defn async def send_prompt(task: TaskRef, text: str) -> None: details = activity.info().heartbeat_details after = details[0] if details else 0 # resume after the last event a dead worker saw async for event in bridge_events(task, text, after=after): await publish_to_page(event) # ≈ PubSub → Phoenix Channel; here Redis → WebSocket activity.heartbeat(event.seq)
Left out: sending the same prompt twice after a retry (the bridge should ignore a prompt ID it has seen), Updates for prompts that need an answer, and the EventBridge rule that signals the workflow when a task stops.