# Architecture

> A serverless control plane and a pull-based worker plane. v1 is one household.

Canonical: https://chattic.us/wiki/architecture/

Chatticus splits into a **control plane** that is always in AWS and a
**worker plane** of heterogeneous computers that pull jobs. v1 is one
household. Every record carries `tenant_id`. The README has mermaid
pictures of **the thin turn** (the computerless path) and **where
we are going** (the v1 product architecture); this page is the protocol.

## Control plane versus workers

```
    User at hey.chattic.us (production) or develop.chattic.us (development)
        |  POST to a per-request front door
        |  server-sent events for one turn
        v
Control plane (AWS, serverless)
  auth, bots, approvals, transcript
  scheduler, SQS, EventBridge, DynamoDB
        |
        |  turn jobs (pull)
        v
+---------------+     +------------------+
| Garage Mac    |     | Fargate / EC2   |
| Docker worker |     | computer image  |
+---------------+     +------------------+
        |                     |
        +----------+----------+
                   |
                   v
            OpenAI API
            (Bedrock later)
```

The control plane accepts work, stores tenant state, and enqueues turns.
It does not run the model loop and it does not own a display.

The control plane is **serverless and holds no persistent sockets**. It
bills per request and costs nothing when nobody is working. The browser
POSTs messages and reads one turn's output as server-sent events. See
[Messaging](/wiki/messaging) for that design and
[Design challenges](/wiki/design-challenges) for why.

Workers register, heartbeat, pull matching jobs, call the model, execute
tools on their computer, and POST coalesced output chunks back to the
same front door. A worker holds no outbound socket either.

## Worker protocol

A worker advertises:

- `worker_id`
- `tenant_id`
- capabilities: `computer`, `browser`, `terminal`, `cpu` -- worker registration
  advertises `terminal`; the agent tool is **`run_terminal`**, granted explicitly
  on a human task (not the conversation preset). Host execution lives in
  `terminal_action_executor.py`, dispatched from `HostActionExecutor` beside
  workspace and browser executors.
- optional `computer_id` (the workplace this process hosts)
- cost class: `local`, `fargate`, or `ec2`
- heartbeat timestamp

A `worker_id` belongs to one tenant's roster. Registration is refused unless the
organization exists and is enabled, and registering an existing `worker_id` again
needs that worker's current bearer token; a host that has lost its token registers
under a new `worker_id`.

A turn job carries:

- `tenant_id`
- optional `user_id` and `bot_id`
- required capabilities
- optional `computer_id` pin (cookies and `/workspace` stay on one workplace)
- computer policy: `prefer_local`, `aws_only`, or `local_only`

Policy is stored on the **computer** and used as the default for that user's
turns. A turn may override it.

Routing:

1. Ignore workers whose heartbeat is stale, whose tenant does not match, or
   who lack required capabilities.
2. If the job is pinned to a `computer_id`, only hosts of **that** workplace
   may take it. Local and AWS workers for the same user share one
   `computer_id`, so a pin can fail over from a garage Mac to Fargate.
3. Under `prefer_local`, choose the healthy local worker if one exists, else a
   warm AWS computer, else request a Fargate start.
4. `aws_only` excludes local. `local_only` never starts AWS.

The control plane never SSHs into the garage. Workers pull from SQS. Home
machines need no inbound ports.

**The worker always dials out.** Watch/takeover display traffic, and file
access for hosts outside the VPC, ride a tunnel the *worker* opens: an
outbound-only, mutually authenticated channel from host to control plane.
No host accepts an inbound connection, and no home router needs a
forwarded port.

That is the requirement. The implementation is an operator deployment
choice -- WireGuard, reverse SSH, SSM Session Manager, or a mesh VPN --
and nothing in the Chatticus codebase imports any of them. Naming a
product here would over-specify a requirement several implementations
satisfy.

ECS Anywhere is optional later. A thin SQS-pull worker is the v1 local
plug-in.

The protocol is substrate-agnostic: a worker advertises capabilities and
a cost class, then pulls. Adding a host type -- a computerless worker, a
warm Kubernetes or Nomad pool, a machine that is on anyway -- means
adding a cost class and its rank, not changing the architecture. A pool
bought *for* Chatticus is an idle floor and is rejected; one that exists
anyway has no marginal cost here.

`prefer_local` is misnamed for what it wants, which is "prefer already
warm and cheapest first". Rename it before a third host type arrives.

The in-memory scheduler that encodes this protocol lives in
`conversation/src/` and is specified by `features/*.feature`.

## The computer image

One Docker image, three hosts:

| Host | When | Disk |
| --- | --- | --- |
| Docker on a Mac | cheapest when the machine is on | local volume hydrated from the S3 snapshot |
| ECS Fargate (ARM64) | v1 AWS computer; burst, scale to 0 | task ephemeral disk hydrated from the S3 snapshot |
| EC2 stop/start | later; closest to an always-ready workplace | EBS as a warm cache of the same snapshot |

The image contains Xvfb (or equivalent) virtual displays, Chromium, a shell,
noVNC or equivalent for watch and takeover, `chatticus-worker`, and
`chatticus-agent`. **The shell is present in the image.** A bot reaches it only
through the granted **`run_terminal`** tool on the summoned host; without that
grant the honest answer is that shell commands are unavailable. v1 AWS computers
run this image on **Fargate ARM64**
(same architecture as Apple Silicon Docker). Scale the Fargate service to
0 when no host is needed. Stop/start EC2 is a later host, not the v1
path.

Multiple virtual displays (`:1`, `:2`, …) are the bot screens.

**Startup ordering is a product requirement, not an implementation
detail.** The container brings up its capabilities independently and
`chatticus-agent` blocks only on the gate it needs:

| Gate | Needed for |
| --- | --- |
| Process and network | Model calls, memory, MCP and connector tools |
| `/workspace` hydrated | File actions and `run_terminal` |
| Browser profile hydrated, display and Chromium up | Browser actions |
| Watch and takeover surface | A human watching or taking over |

Do not add a single "computer ready" barrier in front of the agent.

## Computer snapshots

A host is not the computer. The garage Mac is a host in the same sense
Fargate is a host. Relocating a workplace is **publish to S3, then hydrate
on the next host**. It is not live migration and not `docker save` of the
OS image. The image stays in ECR. The snapshot is `/workspace` plus the
browser profile.

While `hydrate_required` is set, turns for that `computer_id` go only to
the intended host. Prefer-local ranking resumes after hydrate. Unpublished
writes block relocate so a Mac does not start from a stale checkpoint.

Failover when a prefer-local Mac's heartbeat dies is the same hydrate path on
an AWS host, from the last published snapshot. Work that was never
published is gone.

See [Computer snapshots](/wiki/computer-snapshots) and [Computer manifold](/wiki/computer-manifold) (work-kind placement; not implemented).

**Two file layers coexist:**

1. `ControlPlane.read_workspace` / `write_workspace` **refuse**, raising
   `WorkspaceHostOnlyError` ("workspace reads/writes run on the summoned
   computer host"). There is deliberately **one reader, not two**. The
   in-process `computer.workspace` dict survives only as
   `seed_snapshot_workspace`, for relocate protocol specs.
2. `chatticus.snapshot` (`pack_live_disk` / `unpack_live_disk`) packs **real
   directories** on the summoned host -- `/workspace` plus the Chromium
   profile, as [Computer snapshots](/wiki/computer-snapshots) specifies. The
   customer computer host **hydrates on boot** and **publishes before exit**
   when a snapshot store is configured, persisting metadata through the Front
   Door. Host boot records `workspace_ready` only after hydrate completes.

`ControlPlane.publish_snapshot` still bridges the dict into `plane._snapshots`
for relocate protocol specs that run without a host disk. That bridge is not
the durable path on a running host.

**Agent file and terminal tools run on the container's `/workspace` on a summoned host.**
`HostActionExecutor` dispatches `read_workspace`, `write_workspace`, and
`run_terminal` to host executors beside the Chromium executor. Proven end to end on
2026-09-08 against a throwaway customer organization: write, publish to
`chatticus-snapshots-{ORGANIZATION_ID}` in the **customer's own account**, stop
and start the host, read the file back.

**The remaining gap is computerless turns.** A computerless worker has no host,
so a file tool there hits the control-plane refusal above rather than
escalating to a computer-capable host. Wiring that escalation is a later slice
(`chatticus-fccc4e9a`).

The storage conclusions still hold: `chatticus-fbae4e`
proposed replacing host-local disk with EFS and was **measured out** (in-VPC
EFS ran 23-54x local for a mutating tree, ~10x read-only warm --
`chatticus-3d5357`). `/workspace` stays on host-local disk, so publish and
hydrate remain the mechanism and the snapshot library **must not
be deleted**. EFS,
if ever built, is for a shared read-mostly `/org` tree only. Large artifacts
-- datasets, model weights, screenshots -- stay objects in S3.

## What lives where

| Concern | Store |
| --- | --- |
| Bots, memory, skills, routines, approval rules | DynamoDB |
| Transcript (append-only messages, compaction summaries) | DynamoDB |
| In-flight turn chunks | DynamoDB items with a TTL, polled by the streaming function |
| Turn jobs, heartbeats | SQS + scheduler records |
| Routine wake-ups, worker starts, `turn.completed` to device push | EventBridge |
| `/workspace` and browser profile | S3 snapshot (canonical); local volume or EBS as a cache on the current host. **Which account holds the pack depends on the organization:** a customer organization's packs live in ``chatticus-snapshots-{OrganizationId}`` in the **customer's own AWS account**, created when the customer runs the published cross-account CloudFormation template (`infra/customer-role.yml`); the Anthus `ChatticusSnapshots` bucket serves **Anthus-managed organizations only**. Customer Fargate hosts receive ``CHATTICUS_SNAPSHOT_BUCKET`` from the customer ``ChatticusComputers`` stack, not from Anthus. If the bucket is missing, hydrate and publish skip without crashing the host. |
| Secrets | Secrets Manager |
| Object files / artifacts (screenshots, datasets, model weights) | S3 |

Stopping compute does not delete the published snapshot. That is how
Chatticus stays "always able to work" without paying for an always-running
vCPU. A host that is about to run the computer hydrates; it does not mount
the snapshot bucket as the container root.

## Agent loop

The model loop runs **on the worker**.

It starts as soon as the worker has network and memory. It does **not**
wait for the display, Chromium, or a hydrated `/workspace`; those come up
in parallel and gate only the actions that need them. A turn that answers
from memory or works through MCP servers never waits for a browser.

A turn may begin on a **computerless worker** (capability `cpu`, not
`computer`) and escalate when it first needs the computer, by appending
the tool call to the stream and enqueueing a computer-capable job for the
same turn. No state moves: the stream is the handoff. An agent may summon
the computer early with a non-blocking, idempotent `start_computer` tool,
and a caller may declare `computer` in required capabilities at enqueue,
but correctness never depends on either -- touching a computer tool
escalates on its own. See challenge 5 in
[Design challenges](/wiki/design-challenges).

1. Load bot memory, conversation, skills, and the tool list (MCP + computer
   actions + tools from the model provider).
2. Call the configured LLM provider with function calling. v1 uses OpenAI.
   Amazon Bedrock is a later option. The agent talks to a provider interface,
   not a vendor-specific SDK from the rest of the loop.
3. Execute tool calls on the worker (or pause for approval).
4. POST coalesced output chunks (roughly every 250 milliseconds, not one
   per token), screenshots, and approval cards to the control-plane front
   door. Screenshots go to S3 and are referenced, not sent as bytes
   through the API.
5. Persist bot memory and the committed message via the control plane.
   One message row is written at `turn.completed`, never one per token.

Provider-hosted tools (if the vendor offers them) may run on the provider.
Custom tools and computer actions always run on the worker.

On the TypeScript control plane in development, the agent loop runs in a
Lambda that owns the bot's Pi session until the first computer tool call. For
computers in Anthus's own accounts, that call parks the turn and a Pi owner
inside the computer container takes the same session over, runs Pi's own tools
locally and calls the model through a gateway that keeps the real key in the
control plane. Computers in a customer's account never get Pi; they keep the
HTTP host protocol and the host worker. [Pi session handoff](/wiki/pi-session-handoff)
explains this with diagrams, what was proven on development, and the limits.

## Approvals in the loop

Before executing a tool or computer action, the worker asks the control
plane to evaluate auto-review rules for that tenant (and user). Rules do
not leak across tenants. Consequential action types default to
require-approval. The conversation shows the proposed operation. Allow-once
continues that action. Deny blocks it. Always-allow may save a matching
rule.

## Web

The product web app (`hey.chattic.us` in production) is the human surface: bot roster, chat, approval
cards, and a computer preview. It talks only to the control plane. It
does not reach workers directly. It POSTs messages and reads one turn at
a time over server-sent events; it holds nothing open between turns. See
[Messaging](/wiki/messaging).

## v1 tenancy

v1 uses a single household tenant (for example `anthus`). The protocol still
requires `tenant_id` on workers, jobs, bots, and computers. A worker
registered to tenant A must never receive tenant B's jobs. That is the
multi-tenant seam.
