Mainloop keeps project conversations, agent work, and workspace state together. The control plane stores messages and delivery state; native Claude Code and Codex sessions run as kagent Sessions. Branch workspace Sessions have their own git checkout. Mainloop uses attention as a compute budget: active workspaces run, while inactive workspaces are suspended until needed.
Production has run the Go/React stack since 2026-10-12, and read-only smoke run 11 passed. These diagrams describe implemented code paths; they do not claim further live cluster proof. Known verification gaps are listed below.
flowchart LR
person["Browser or phone"] --> app["React app"]
app <--> api["Go server (serve-api)"]
api --> data[("PostgreSQL<br/>messages, deliveries, workspace state")]
api -->|"agent turns (A2A) and Sessions"| kagent["kagent gateway"]
person -->|tailnet preview URL| preview["Mainloop preview proxy"]
preview --> router["Substrate router"]
subgraph workspace["One kagent Session actor per workspace"]
runtime["Harness container<br/>checkout, native CLI, dev server"]
end
kagent --> workspace
router --> workspace
Mainloop owns conversations, message delivery, workspace policy, and the mapping from native sessions to workspaces. kagent runs the Sessions and their actors. Native agent turns go to kagent over A2A; agent sessions keep their native session identity, history, and tools. The preview proxy checks the owner and allowed ports before sending traffic through the router.
Think of workspace compute like foveated rendering in VR: what needs attention is loaded and
running; the rest is parked. Here, parking means that Mainloop asks kagent to suspend the Session.
An idle workspace is still present in Mainloop; idle is the waiting period before suspension,
not a separate kagent state.
stateDiagram-v2
[*] --> Running: workspace created
Running --> Idle: no turn or preview activity
Idle --> Running: new turn or preview activity
Idle --> Parked: configured idle timeout expires
Parked --> Waking: message, preview request, or resume action
Waking --> Running: actor restored and healthy
Waking --> Parked: actor confirmed still suspended
Waking --> Unknown: wake result is not confirmed
Each workspace sets its idle timeout (dev.idle_timeout_minutes); the default is 30 minutes.
Mainloop checks for expired workspaces about once a minute. Turns, resumes and preview requests
count as activity. Opening a workspace page does not wake its actor. Deliveries in recorded,
queued (unless held after stop), sending or delivered states block parking. The check runs under the per-session
lock of the REST process, which orders it with that process's own deliveries. That lock does not
reach the MCP container, a second writer in the same pod, so a message it records during a
suspend can still arrive. Delivery handles that case: a turn that finds the Session suspended
resumes it before sending. A new message resumes a suspended Session, and so does a preview
request (below).
One open turn per session is enforced in PostgreSQL, not in memory. Recording a message takes
a transaction-scoped PostgreSQL advisory lock (pg_advisory_xact_lock) on the session's key, then decides recorded or queued from
the open deliveries in the same transaction. The REST and MCP containers both write the ledger,
so the in-process lock only orders work inside one process.
No wake or suspend latency has been measured against kagent. The live checks that are still
owed are listed in docs/specs/workspaces.md.
sequenceDiagram
actor Owner
participant UI as React app
participant Mainloop as Go server (serve-api)
participant DB as PostgreSQL
participant Kagent as kagent gateway
participant Agent as Native agent session
Owner->>UI: Send a message
UI->>Mainloop: Submit message
Mainloop->>DB: Record message and delivery under workspace lock
Mainloop->>Kagent: Create or resume the Session
Mainloop->>Kagent: A2A SendStreamingMessage (messageId = delivery id)
Kagent->>Agent: Run the turn in the native session
Agent-->>Kagent: Task status and artifacts
Kagent-->>Mainloop: Stream of task events
Mainloop->>DB: Save delivery outcome and the mirrored reply
UI->>Mainloop: Poll the conversation for the reply
Recording the delivery before connecting gives Mainloop a stable delivery to reconcile if a
connection drops. kagent keeps no event cursor, so after a drop or restart Mainloop reads the
current task (GetTask, or the first event of SubscribeToTask) and replaces its projection
with it. KAGENT_SEND_NOT_ACCEPTED is retried with the same messageId; any other uncertain
outcome is resolved by finding the task that holds that messageId, never by sending again.
Mainloop calls kagent as the fixed service principal mainloop. With
KAGENT_CONTROL_TOKEN_FILE configured, the backend validates a bounded mounted Secret file
at startup and rereads it for every lifecycle, discovery and A2A request/reconnect. It sends
only a bearer Authorization header, and requires KAGENT_USER_ID=mainloop as expected
principal configuration. Auth refusals are service configuration failures: they never mean
a Session was deleted, and cannot cause replacement or turn resend. Unknown delivery still
requires observation. With the token file unset, legacy insecure x-user-id: KAGENT_USER_ID
behavior is preserved; changing that legacy identity loses access to existing Sessions.
Server enforcement depends on enabling kagent's service-token mode and default-deny policy
in deployment, with a trusted HTTPS control endpoint and actor exclusion. This enforcement
is not yet deployed. The control bearer is backend-only, separate from provider authentication
and per-binding MCP grants; see credentials. A genuine NOT_FOUND or
deleted Session can still be replaced on the next message under the existing lifecycle rules.
A workspace is a kagent Session created with a checkout request (repository, ref, branch, depth). The request is stored and resent unchanged if the Session is replaced. A workspace declares its preview ports and idle timeout. Archiving a session or deleting the workspace deletes its kagent Session.
flowchart LR
browser["Browser"] --> url["Preview URL<br/>port--workspace--preview.domain"]
url --> proxy["Mainloop preview proxy<br/>owner and port checks"]
proxy --> router["Substrate router<br/>CONNECT actor-upstream:port"]
router --> actor["Session actor<br/>dev server"]
The preview URL is https://<port>--<workspace>--preview.<domain>. The preview proxy checks that
the workspace is the configured owner's and that the port is declared, then reaches the Session's actor
through the router. With onQuiesce: Full on the harness, the router would wake a suspended
actor without telling kagent, leaving the Session suspended and invisible to idle-out. So before
connecting, the proxy resumes a suspended Session through kagent (ResumeSession, the same path as
the resume endpoint), once however many previews arrive together. If kagent cannot be asked, the
preview fails (502, WebSocket close 1013) without touching the router. Idle-out then suspends
the workspace again after its idle timeout.
- Preview CONNECT traffic goes through the Substrate router, which has no authentication of its own; the owner and port checks live in Mainloop.
- The backend reaches Substrate only through the router CONNECT path.
- Workspace lifecycle, preview and idle-out have fake-backed and Postgres-backed tests. They were
also verified live on a Kind cluster before the Go/React cutover, on the retired Python backend:
create, first and second turn, idle-out, a message to a suspended workspace, a suspend racing a
message, wake-on-preview from a suspended workspace (one
ResumeSessionfor concurrent previews) and stopping a turn. Still unverified live: the cold-clone first turn against the client's 30 second timeout, the K1 negative cases, stopping a parked turn, WebSocket previews of a suspended workspace, and the review repairs (they were not redeployed).docs/specs/workspaces.mdlists both. No live write-path run of the Go server is recorded here yet. - The Agent Harness's git origins and
onQuiesce: Fullare kagent installation settings.
| Term | Meaning |
|---|---|
| Session | A kagent Session: one native agent conversation and the actor it runs in. |
| Workspace | The git checkout a Session is created with, plus its preview ports and idle timeout. |
| Actor | The Substrate sandbox kagent runs a Session in. |
| Router | Substrate's control path for connecting Mainloop to an actor. |
| Park | Suspend an idle Session so its compute is not running until needed. |
Default-off Git enrollment stores an immutable original create plan, purpose hashes and current field-20 runtime association in PostgreSQL. Native creation freezes MCP/read/optional-push refs before bytes; durable owned non-turn warmup and fresh GetSession precede usable Git publication. Native send confirms required publication. Injectable listeners use a PostgreSQL adapter with one caller-owned connection through ordered dispatch/outcome locks. Each quarantine seed read revalidates the same credential stamp. No transaction spans external I/O.
The existing publication ledger fences unresolved Git writes by owner/repository/branch across
rotation, restart and successors. Cancellation revokes both purposes while retaining source,
claim and operation state. Independent cleanup tombstones delete Git Secrets with UID
preconditions. Tracked source and GitOps manifests include the git container (serve-git) from the backend image
sidecar, mainloop-git-read / mainloop-git-push port-80 Services, and ingress restricted to
ate-system pods labelled app=atenet-egress on listener ports 8003/8004. The upstream uses
repository-scoped GitHub App installation tokens with contents: read for fetches and
contents: write for pushes. GIT_TRANSPORT_ENABLED and PUSH_GATE_ENABLED remain default off;
disabled listeners refuse traffic. This tracked wiring is not proof of a deployed image,
GitOps rollout or live Actor containment. Native handoff/retention/checkout adapters and
live/runtime/package gates remain separate.
See Git authority.
Native agents use the mainloop MCP server, a dedicated stateless Streamable HTTP listener
on port 8002. Service mainloop-mcp serves port 80 at
http://mainloop-mcp.mainloop.svc.cluster.local/mcp; it exposes no REST API.
The Substrate egress gateway replaces the agent's literal Authorization: Bearer kagent-credential-injected placeholder with its binding credential. Mainloop stores a token
hash on the binding and creates a runtime-owned kagent/mainloop-mcp-<credential-identity> Secret
with the full header in its authorization key, labeled mainloop.dev/actor-egress=true
and mainloop.dev/purpose=mcp. Terminal or archived bindings lose tool access; cleanup
deletes their Secret.
Ordinary bindings use the binding ID as their credential identity. An explicit idle
main-provider switch rotates that identity while retaining the product session, child
ownership and report routing. It suspends and records the old native Session in
main_native_history, then provisions the persisted new native create contract. Main's
Agent reference is pinned with that contract. Chat is blocked and reports are held during
preparation; an owner repeats the same selection to recover an interrupted operation.
Recovery uses the pinned contract independently of current catalog admission. A confirmed
absent or settled failed preparation target can be explicitly abandoned for a fresh identity;
uncertain creation retains its original request. MCP intent commits share the retirement
authority lock, and delivery completion, reply and turn accounting commit atomically.
The current main-owner slot belongs to a valid coordination binding. Bootstrap releases
an inactive binding's slot and revokes its remaining bearer in the same transaction that
claims its replacement, retaining old native contracts, history and delivery receipts.
native_bindings.main_user_id persists historical main membership separately from the
unique main_owner_id reservation. It survives role/authority changes and migration replay.
Provider operations require a valid main when historical membership exists; only ordinary
bootstrap can establish a replacement. True first use can still create a main. Migration
backfills membership once from retained main role, reservation, pinned contract or native history,
without treating ordinary workers/children in the same main thread as main bindings.
See main-provider selection.
A NetworkPolicy-enforcing CNI is required. The REST API has no application authorization;
its port 8000 must admit only the frontend and the Tailscale gateway. MCP port 8002 admits
only ate-system egress pods labelled app: atenet-egress. Verify those gateway pod labels
and the Tailscale gateway selector against the installation, and prove blocked connections
fail before deployment. A default Kind CNI does not provide this enforcement. The cleartext
gateway-to-Mainloop hop depends on this isolation and the pinned stock agentgateway path;
never expose the MCP Service outside the cluster.
The base includes the dedicated MCP container, Service and ingress policy. Cross-namespace
integration resources are separately rendered with k8s/integrations/kagent: the
Role/RoleBinding and RemoteMCPServer. Binding Secrets are created by Mainloop at runtime.
The trusted backend Role grants namespace-wide Secret get/create/delete in kagent.
RBAC cannot constrain dynamic creates by name or label; admission policy can restrict writes,
while prefix-scoped reads require a separate broker or authorization boundary.
Main and child AgentTemplates must bind that RemoteMCPServer.
Mainloop uses three kinds of kagent Agent, named by settings. Agent templates and harnesses
remain owned by the kagent installation.
| Setting | Runs | Harness requirements |
|---|---|---|
KAGENT_MAIN_AGENT (mainloop-main) |
the Claude main thread | sessionIdleTTL: 0s |
KAGENT_MAIN_CODEX_AGENT (mainloop-main-codex) |
the Codex main thread | sessionIdleTTL: 0s |
KAGENT_WORKSPACE_CLAUDE_AGENT / _CODEX_AGENT |
workspaces and sessions the owner starts | sessionIdleTTL: 0s, git.origins (the repository hosts), snapshotPolicy.onQuiesce: Full |
KAGENT_CLAUDE_AGENT / KAGENT_CODEX_AGENT |
child agents the main thread delegates to | default TTL and snapshot scope |
A workspace needs its own Agents because kagent measures idle time from the last A2A event
(previews and ResumeSession do not count) and deletes the Session and its actor on expiry,
along with uncommitted and unpushed work. onQuiesce: Full keeps files and the dev server across
a suspend, which previews need, but costs a full snapshot at every quiesce, which children should
not pay. The main thread is exempt from Mainloop idle-out. Mainloop parks idle workspace compute itself (SuspendSession); archiving or deleting ends the
Session. Main and child templates bind the mainloop RemoteMCPServer. Standalone workspace Agents
have no Mainloop tool identity and do not bind that server. Gateway port 8083 must admit only
Mainloop, and TaskStore must admit only actors, using installation-specific policies.
The supported gateway is stock Substrate v0.3.0-alpha3 using agentgateway revision
50999825cb55904801f7fd6b18b865179d0d50c4, image digest
sha256:f1907a50b2e74a071da53fcd2008d585b6a63d31b4e1ba3ee46cf22b342cf04b.
That dataplane injects complete header values on HTTP and HTTPS when a configured placeholder
header is present. The credential provider must authorize the actor's atespace to read the
per-binding Secrets in kagent, selected by mainloop.dev/actor-egress=true; Mainloop's
namespace-scoped publisher Role does not grant the provider that access. Keep this version
pinned and repeat the gateway regression proof on every Substrate/agentgateway upgrade: HTTP injection is not a portable guarantee of
other dataplanes. The proposed atenet cleartext allowlist patch is parked and is not required.
The hash-only live gateway proof established HTTP/HTTPS injection and placeholder
isolation on that pinned stock dataplane. The joint proof (a native agent calling the Mainloop MCP
server through the gateway, and blocked connections) passed on a Cilium cluster; the Kind
overlay's default CNI does not enforce NetworkPolicy, so the blocked-connection check needs a
policy-enforcing CNI. The Secret holds the complete Bearer ml_… header value; NetworkPolicy
remains required.
The sections above describe the implemented code. The migration and its direction are in ROADMAP.md.
| Path | Role |
|---|---|
api/ |
Wire contract (openapi.yaml) and the MCP tool manifest. Go and TypeScript types are generated from it. |
server/ |
Go server, one binary: serve-api, serve-mcp, serve-git, migrate and operator commands. |
web/ |
React app (Vite) and its static server (web/serve). |
backend/, models/, frontend/, packages/ui/ |
Retired Python and SvelteKit code, replaced by server/ and web/. Git history before e776537 has it. |
Durable work runs as a Postgres reconciler; DBOS is removed. Each side effect is an intent row with an explicit state and an idempotency key, and no database lock is held across network I/O. Deployment changes are image and commit pin bumps applied through GitOps.