The Then = persistent agent kind of session: a session that keeps its memory between turns and is woken by messages instead of exiting. In the New Session form it is the third exit condition, next to exits when done and waits for me; in the Sessions feed it is the Agents view.
Exits when done — the agent runs in a repo worktree (opens a PR) or a pooled pod (side effects), then terminates. Waits for me — an interactive terminal; the agent halts at its prompt between turns. Persistent agent — long-lived, named, message-driven. Doesn't terminate. Other agents can message it, which is how a set of agents becomes a swarm.
A Persistent Agent (PA) executes one turn of work, halts, and waits to be
re-woken by a user message, an agent message, a webhook, a cron tick, or a
ticket event. Each PA has a stable slug and is addressable by other PAs in
the same workspace via the inter-agent HTTP API.
Inspiration: Scion + scion-athenaeum. The "service model" rather than the "job model" — turns are the inputs, not the unit.
| Job model (sessions that exit) | Service model (Persistent Agents) | |
|---|---|---|
| Identity | The run | The agent itself |
| Lifecycle | One-shot | Cyclic — turns until paused/archived |
| Inputs | Params | Messages |
| Outputs | Logs + PR or side effects | Messages + side effects |
| Addressing | Run ID | agent:<workspace>/<slug> |
idle ── pending msg / intent ──▶ queued ──▶ provisioning ──▶ running
▲ │
└────────────────── turn halted (success) ─────────────────────┘
Failed turns are retried by re-waking until consecutive_failure_limit is
exceeded, at which point the PA transitions to failed and requires manual
resume. Other terminal-ish states: paused (manual pause/resume),
archived (terminal, kept for history).
Configurable per agent (default sticky):
| Mode | Behavior | Cost | Latency |
|---|---|---|---|
always-on |
Pod stays running until the agent is paused/archived. | Highest | Instant |
sticky |
Pod kept warm for idle_pod_timeout_ms after each turn; cold-restart otherwise. |
Medium | Fast (in window) / Slow (cold) |
on-demand |
Cold-start every turn. | Lowest | Slow |
Pick always-on for high-frequency agents (event handlers, monitors),
sticky for normal interactive agents, on-demand for low-frequency
scheduled agents (e.g. nightly digest).
When a turn runs, drained messages are formatted into the prompt as:
---BEGIN OPTIO MESSAGE---
{
"version": 1,
"timestamp": "2026-04-26T10:00:00Z",
"sender": "agent:acme/forge",
"type": "instruction",
"broadcasted": false,
"body": "Spec for /healthz endpoint:\n…"
}
---END OPTIO MESSAGE---
sender follows the format <type>:<id>:
user:<email|id>agent:<workspace>/<slug>system:<label>(scheduler, init, …)external:<label>(webhook path, …)
Every PA pod gets OPTIO_API_URL and OPTIO_AGENT_TOKEN env vars. The
agent's agents.md operator manual documents the verbs and the agent learns
the API on its own — no special MCP server required (Scion's "agents learn
the CLI" philosophy).
| Verb | Purpose |
|---|---|
GET /api/internal/persistent-agents |
List addressable agents in workspace |
POST /api/internal/persistent-agents/send |
Direct message: { to: <slug>, body } |
POST /api/internal/persistent-agents/broadcast |
Broadcast to all peers: { body } |
GET /api/internal/persistent-agents/inbox |
Read your own recent messages |
Auth: X-Optio-Agent-Token: $OPTIO_AGENT_TOKEN header. v0.4 uses the agent's
own UUID as the token; a follow-up will swap to per-turn signed tokens.
| Source | Trigger |
|---|---|
user |
UI message from a human via POST /api/persistent-agents/:id/messages |
agent |
Another PA used the inter-agent API |
webhook |
workflow_triggers webhook with target_type='persistent_agent' |
schedule |
Cron trigger fired |
ticket |
Linear/GitHub issue event (when wired) |
system |
Internal (init, restart, ...) |
PAs reuse the existing workflow_triggers polymorphic table. Add a trigger
with target_type='persistent_agent', target_id=<agent.id>, and a regular
type (schedule, webhook, ticket). The trigger worker dispatches
through wakeAgent() instead of createWorkflowRun().
PAs do not (yet) use native CLI session-resume across turns. Each turn is a fresh agent invocation, with three sources of continuity:
- System prompt + agents.md — constant across all turns.
- Drained inbox messages — assembled into the turn's prompt.
/workspace/— pod-local filesystem. Sticky/always-on lifecycle modes preserve files across turns;on-demanddoes not. Pattern: agent maintains its ownMEMORY.md, journals, etc. (see Chronicler in the demo).
Native session resume (e.g. claude --resume) is a planned upgrade.
An agent can work in one of its workspace's repos (repo_id, set from the
New work form's Where). Each turn runs in one checkout of it at
/workspace/repo (CHECKOUT_REPO in apps/api/src/utils/pod-env.ts): cloned
on the first turn at the agent's branch, then only fetched, so what the agent
left there — its own branches, uncommitted notes — stays between turns. Point
the agent at another branch or repo and the next turn follows (a checkout, or
a fresh clone). The turn signs in to git with a short-lived GitHub App
installation token (or the workspace's tokens) — never Optio's credential
secret, which could sign requests for other runs' tokens. A GitHub event
trigger with no repos filter listens to the agent's own repo.
Like every pod run, a turn gets the workspace's (and its repo's) MCP servers,
connections, and skills, with the agent's own settings adding or removing
them and its setup commands run first (buildAgentEnvironment).
- Turn errors increment
consecutive_failures. - After
consecutive_failure_limit(default 3), the PA transitions tofailedand requires manualresumefrom the UI. - The
last_failure_reasonandlast_failure_atare surfaced in the UI. - Successful turns reset the counter to 0.
A four-agent engineering team:
- Vesper — architect (decomposes feature requests)
- Forge — implementer (drafts code)
- Sentinel — reviewer
- Chronicler — scribe (maintains team journal)
A self-contained, runnable copy lives at
examples/persistent-agents/forge/.
For more runnable examples (including the seven-agent
Mars Mission Control
incident-response scenario), see examples/README.md.
PAs are reconciled by the existing K8s-style reconciler. New RunKind is
persistent-agent. The pure decision function lives in
packages/shared/src/reconcile/reconcile-persistent-agent.ts. Producers
that wake the reconciler:
- Message arrives in inbox (
wakeAgent) - Trigger fires (worker dispatch)
- Control intent set (UI/API)
- Turn completes (worker
finally) - Periodic resync (every 5 min)
See migration 1777200001_persistent_agents.sql. Tables:
persistent_agents— the agent itselfpersistent_agent_turns— per-turn recordpersistent_agent_messages— inbox (pending + processed)
A turn's log lines live in the one log table every run uses, task_logs,
keyed by persistent_agent_turn_id; an agent's pod lives in the one pod
table, agent_pods (pool = 'persistent-agent', pool_key = the agent's
id), with keep_warm_until for the cleanup worker (null = always-on).
- Native session-resume (
claude --resume <id>) for context continuity within the same agent runtime, when sticky/always-on pods are used. - Replace the agent-id-as-token shortcut with per-turn signed tokens that expire on turn completion.
- A proper stdio MCP server (
@optio/mcp-agents) wrapping the same HTTP verbs, auto-injected into PA pods via.mcp.json. - Per-agent permission scoping for inter-agent messaging (currently workspace-wide).
- The Sessions UX (terminal + chat) as a rendering layer for agents with a repo.