Lucia: An Open-Source Builder for AI Agents That Run for Weeks
Summary
Lucia is an open-source platform for building AI agents that work on one goal for days or weeks. They follow up over email, Slack and phone, react to replies, and bring in a human only when they're stuck. You define an agent as config, prove it in a simulator where six weeks pass in about two minutes, then publish it. One shared runtime runs every agent.
The kind of work it's for
A lot of jobs look like this: get a document back from someone, check in every week, escalate if nobody answers, report when something changes. None of it is hard. It's slow, it spans weeks, and it gets dropped.
Most agent frameworks assume the task finishes in one sitting. You send a prompt, the agent calls some tools, you get an answer. A follow-up agent can't work like that. It has to sleep for three days, wake up, remember what it already sent, and not send it again.
An agent is a config file
In Lucia, a builder describes an agent instead of coding one:
- what it can do (its capabilities)
- task templates and when a task counts as done
- the follow-up policy: how often, on which channel, when to escalate
- tools it may call and the compliance rules it must follow
- eval cases it has to pass before it ships
The config is validated and compiled. Once published, a version is read-only. Changing it creates a new version, and every run remembers which version it started on.
An organization then gets mapped to a published version, with its own identities: its own mailbox, Slack bot and phone number. The agent is global; the identities belong to the org.
Runs, Episodes, and why nothing stays in memory
A run is the long-lived thing. It holds the goal, the plan, a journal of what happened, and its status. It can live for weeks, so it can't live in a Python process. It lives in Postgres.
A run moves forward in Episodes. An Episode is one unit of work, and it's both the trigger and the execution:
- a scheduled follow-up firing
- an inbound email or Slack mention
- a phone call report
- a human answering a question the agent asked
Each one becomes a row, gets queued, and runs one at a time per run. A Celery worker picks it up, loads the run's context, runs the model through the OpenAI Agents SDK, executes the tool calls, and closes the Episode. Then the run goes back to sleep until the next one. Every future wake-up is just a scheduled Episode row.
One worker per run: leases with fencing
If two workers advance the same run at once, you get two follow-up emails. So a worker has to claim the run first.
- Claim. In one short transaction: if the run's lease is empty or expired, set yourself as the owner, bump
lease_epoch, and set a 90-second expiry. - Heartbeat. Every 30 seconds, extend the lease, but only
WHERE lease_epoch = <mine>. If that update fails, someone else has the run. Stop immediately. - Fence. Before writing any step, re-read the run's
lease_epoch. If it changed, abort.
The third step is the one people skip. A worker can pause (a long GC, a slow network call), lose its lease, and wake up still believing it owns the run. The epoch check stops that zombie from writing anything.
Not sending the same email twice
Tool calls that reach the outside world go through a ToolExecutor: policy check, a small-model guardrail for anything outbound, then an outbox.
The outbox works off an idempotency key that's built from runtime state, never from what the model wrote:
idem_key = sha256(run_id | episode_id | task_key | tool | normalized_recipient | ordinal)ordinal counts calls to the same tool and recipient within the Episode, so two legitimate emails to the same person don't collide. If the model rewrites the email after a crash, the key is the same, so it's still the same send.
The hard case is a crash after the email went out but before the database recorded it. On restart, the executor asks the connector whether a message with that key was sent. Gmail messages carry the key in a header, and Vapi calls carry it in metadata.
- Found: adopt the result.
- Confirmed not sent: send it.
- The lookup itself fails: don't send. Mark it uncertain and ask a human, with "it was sent" and "it was not sent" buttons.
So it's effectively once in the normal case, and at most once when unsure. For an agent that writes to real people on someone's behalf, I'd rather it stop and ask than double-send.
Testing six weeks in two minutes
You can't test a follow-up agent by waiting for real follow-ups. So the simulator runs the same runtime and the same ToolExecutor, with two swaps:
- Sandbox connectors. Each contact is an LLM persona, like "replies after 3 days, asks for a fax on the 2nd follow-up". Voice calls are simulated as transcripts.
- A virtual clock.
SimClockandSimSchedulerfire scheduled Episodes instantly in virtual time. A six-week run takes about two minutes.
A simulated run is a normal run with sim=true, so you debug it in the same screens as production. Eval cases come from hand-written golden cases shipped with each template and from generated cases a builder has to review. A scorecard gates publishing.
The stack
| Layer | Choice |
|---|---|
| API | Python 3.12, FastAPI, SQLAlchemy 2 (async), Alembic |
| Runtime | Celery workers and beat on Redis, OpenAI Agents SDK |
| Storage | Postgres 16 |
| Studio | React, Vite, TypeScript, Tailwind v4, shadcn/ui, TanStack Router and Query |
| Connectors | Gmail (with Pub/Sub push), Slack, Vapi for outbound calls |
Try it
The code is on GitHub. make setup, make db-up, make migrate, make seed and make dev get you a local Studio with template agents. It's still a work in progress. The project page is here.
References
- Lucia on GitHub
- OpenAI Agents SDK
- Martin Kleppmann, "How to do distributed locking" (the fencing token idea)
- Celery