Design challenges
Read this before adding a control-plane API, a message store, or a streaming path for the product web app.
It has four parts:
- Requirements: what must hold or the product is broken.
- Non-requirements: what looks like a requirement and is not. Read this one. Mistaking any of them for a requirement is how this design gets over-built.
- Derived rules: consequences of the two above, each naming the requirement it comes from.
- Challenges: 1 and 2 are decided, 3 has a working model with open sub-questions, and 4 is open.
The reasoning is kept with each decision. A decision whose premise is not written down cannot be re-checked when the premise changes, and every premise here is one AWS price change or one product requirement away from moving.
Requirements
These must hold or the product is broken. A proposal that violates one is wrong no matter how cheap or elegant it is.
Product
- A bot is persistent. Memory and context compound across turns. It is not a fresh session per task.
- One computer per user, shared by every bot on that user. The user is the security boundary.
- Work continues when the human's device is closed or offline.
- Consequential actions are gated by approval. Passwords, codes, and identity checks are a takeover of the computer, never text in chat.
- The human is not the router between bots.
- The human can watch bot-to-bot work. The mechanism is open; the visibility is not.
Platform
- Nothing bills while nobody is working. No component has an idle floor. This is the principle the cloud API is built to satisfy.
- Output reaches the browser as it is produced. A human watching a turn sees it progress. See also requirement 16: this starts at the beginning of the turn, not once the computer is ready.
- The transcript is durable, append-only, immutable, and reloadable from a cursor.
- The stream is fully re-derivable from the store. Work never depends on anyone watching.
- The tenant seam survives without a rewrite. Chatticus will serve other households. v1 being one household does not make this aspirational.
- Workers pull. The control plane never reaches into a worker, and a home machine needs no inbound ports.
- The model loop runs on the worker, never on the control plane.
- One computer image runs on Fargate, EC2, and local Docker.
- The web app talks only to the control plane, never to a worker.
- A bot begins responding immediately, even while its computer is still booting. Getting the computer ready is concurrent with the turn, not a gate in front of it. A cold computer delays the bot's first computer action; it must not delay the bot's first word.
Non-requirements
These look like requirements and are not. Each one, mistaken for a requirement, would push the design toward something more complex and more expensive than the product needs. When a proposal is justified by one of these, that is the tell.
-
Token-by-token delivery. The requirement is that a human watching a turn sees it progress. Chunks of roughly 250 milliseconds satisfy that completely. This is what licenses coalescing, and coalescing is what makes the per-event cost of everything downstream negligible.
-
Lowest total cost. The requirement is zero at idle. A design with a lower average bill but an idle floor loses to one that costs slightly more per turn and nothing at rest.
-
A bounded cold start for the computer. Two different latencies hide under "cold start", and only one of them is slack:
- Time to the bot's first word. Fast, always. This is requirement 16, not a non-requirement.
- Time to the bot's first computer action. Unmeasured and unbounded for now. Image pull plus display and browser startup, paid only when nothing is warm.
The second is acceptable because the first is fast: the wait happens behind visible work rather than in front of it. Revisit when there is a real image and a real turn to measure; do not spend engineering on shrinking it before then. See challenge 5.
-
Throughput, or anything resembling web scale. One household, a handful of concurrent turns. Serverless here is about the idle floor, not about scale. Do not engineer for load that does not exist. This is the most likely way to over-build from a correct decision.
-
Bidirectional realtime. Approvals are rare and human-paced. A POST serves them. Nothing needs a duplex channel.
-
Live updates in a tab with no turn running. Device push, and a cheap poll, cover "something finished while you were away."
-
Durable in-flight chunks. Chunks are disposable. Only the committed message is durable. Losing chunks costs a re-read, never work.
-
Exactly-once chunk delivery. Chunks are idempotent by turn and sequence, and re-readable.
-
Ordering across channels. Only order within a channel is guaranteed, and
seqat commit already provides it. -
Edits and deletes. Out of scope for v1.
-
Bots as a security boundary. They are not, and no design should imply they are. Separate screens are work surfaces, not isolation.
-
Live migration of a running computer. Publish a snapshot, then hydrate on the next host.
-
Multi-region or high availability. Not v1.
-
Sub-second lifecycle events. Routine wake-ups, worker starts, and device push tolerate seconds.
Derived rules
These are consequences, not axioms. Each names the requirement it comes from, so that if the premise moves the rule is re-derived rather than cited out of habit.
| Rule | Follows from |
|---|---|
| No transport that meters per connection or per message | 7 |
| No persistent sockets, browser-side or worker-side | 7 |
| A stream is scoped to one turn, never to a tab or a login session | 7, 8 |
| No always-on datastore; no relational instance | 7 |
| No load balancer in front of the API (hourly floor) | 7 |
| The worker/browser rendezvous is a readable store with per-reader cursors | 10 |
| Output is coalesced into chunks, not sent per token | non-requirement 1 |
Reaping is metered per use, and that is allowed
The ownership work in challenge 5 needs a watchdog or reaper so a dead or wedged turn releases its claim without an always-on scheduler. That watchdog transport (DynamoDB TTL with Streams, or EventBridge Scheduler) is metered per event, which looks like it collides with the rule above: "no transport that meters per connection or per message."
It does not. Re-derive from requirement 7, as the rule itself demands. A metered transport bills for use, not for capacity. A reaper that fires once per turn, only when a turn fails to renew, has no idle floor and scales with work actually done. That is the same shape as SQS and EventBridge, both already in the design. The forbidden shape is a meter that scales with how much the product is used while sitting idle (AppSync per update) or a process held open for a session. A rare, per-turn scheduled wake-up is neither.
State this where the reaper is specified so a later reader does not cite the per-message rule at it and either drop the reaper or quietly add an always-on process. The reaper is a requirement 7 mechanism, not an exception to it.
Why "no sockets" is a cost rule, not a taste
The ban on persistent sockets is worth stating precisely, because the imprecise version will be misapplied.
We have paid large AppSync bills. That is the direct evidence, and it is better than any arithmetic in this document. But the lesson is not "open connections are inherently expensive." A socket held by a process you are already paying for continuously costs nothing extra per connection.
A socket is expensive in two specific ways, and both trace to requirement 7:
- A managed socket service meters it. Per update, per operation, per connection-minute. That is the AppSync bill, and it scales with how much the product is used rather than with how much capacity it needs.
- A self-held socket requires a process that outlives the request. That process has an idle floor whether or not anyone is connected.
So the rule is derived, not independent. The practical consequence: if someone later proposes a socket on a process we are already running continuously for unrelated reasons, do not cite this rule at them. Go back to requirement 7 and re-derive. The answer may legitimately differ.
How to record a rejection
Every rejected option below says which kind of rejection it is:
- Shape. The service cannot do the job, or can only do it through an extra hop. Stable. Does not change when prices change.
- Cost. The service can do the job and the arithmetic says no. Valid only under stated assumptions. Write the assumption next to it.
The first version of this document mixed the two. Three rejections that read as absolutes ("Lambda is for seconds-long work", "polling is too wasteful for tokens", "AppSync bills per update") were arithmetic conditioned on assumptions we then changed. Keep them separate so a future reader can tell what is settled from what is merely priced.
Approximate prices appear below as orders of magnitude, current as of 2026. Re-verify any number that is carrying an argument before it becomes a build decision.
1. Cloud API: scale to zero and stream
Decided. No persistent sockets anywhere. The whole control plane bills per request and costs nothing when nobody is working.
Tested (2026-08-30). Test 1 in spikes/sse-transport/ measured Python Lambda
response streaming (Lambda Web Adapter) through a function URL and through
CloudFront. Pass. No failure branch applied. Spike stack ChatticusSseSpike
in us-east-1. Endpoints were stack outputs at measurement time; do not
commit those URLs.
| Path | TTFB (ms) | Inter-frame gap p50 (ms) | p95 (ms) | Batching | Reconnect (Last-Event-ID) |
|---|---|---|---|---|---|
| Direct function URL | 69-223 | 250-251 | 307-367 | Rare same-tick pairs; not multi-second batches | seq 20 after server close at 19 |
| CloudFront | 127-293 | 250-251 | 284-333 | Same as direct | seq 20 after server close at 19 |
Raw JSON: spikes/sse-transport/results/summary.json.
Transport. Python 3.12 on Lambda with Lambda Web Adapter
(AWS_LWA_INVOKE_MODE=response_stream), waitress WSGI server, function URL
InvokeMode.RESPONSE_STREAM, 900 s timeout. CloudFront distribution with
CachingDisabled, viewer-request function stripping Accept-Encoding, origin
read timeout 60 s (180 s quota increase not available on this account).
Public function URL origin (OAC rejected for spike because it requires
AWS_IAM auth). No ALB.
Disconnect boundaries.
- Direct: Lambda 900 s hard stop observed (
direct_boundary_probe.json: 3596 frames in 900.4 s). Client must reconnect withLast-Event-IDfor turns longer than 15 minutes. - CloudFront: with 250 ms chunks arriving continuously, a 90 s foreground run delivered all 360 frames with no mid-stream disconnect. Origin read timeout did not cut an active chunked stream in this test; idle-gap behavior was not probed to 15 minutes. Production still plans reconnect-at-timeout for long gaps.
Background tab and mobile Safari. Neither was measured. A Playwright run can
mock document.hidden, but that is not a real backgrounded tab and is not
reported here. See spikes/sse-transport/README.md for a mobile Safari
checklist.
Front door. Lambda function URL behind CloudFront is viable for the streaming path. API Gateway HTTP API was not needed. Custom domain was not tested.
Caveats seeded during the spike.
- Duplicate
Access-Control-Allow-Originif both Flask and function URL CORS emit headers; production must configure CORS in one place only. - WSGI cannot set
Connection: keep-alive(PEP 3333); omit it. - CloudFront TTFB added roughly 50-100 ms over direct in warm runs, not the 500-800 ms sometimes reported for cold function URLs.
The shape
See the sequence diagram in Messaging.
| Piece | Choice | Idle cost |
|---|---|---|
| HTTP front door | Per-request HTTP (API Gateway HTTP API, or a Lambda function URL behind CloudFront) | zero |
| Live token stream | Server-sent events held by a streaming function, scoped to one turn | zero |
| Rendezvous between worker and browser | DynamoDB items keyed by turn and sequence, with a TTL | near zero |
| Turn jobs | SQS | zero |
| Lifecycle events, routine wake-ups | EventBridge | zero |
| Transcript | DynamoDB (see challenge 2) | near zero |
Never put a load balancer in front of this. An Application Load Balancer has an hourly floor of roughly 16 to 18 dollars a month, which is more than the always-on container this design exists to avoid. The front door must be a service that bills per request and has no hourly charge. This is the single easiest way to accidentally build something more expensive than the thing it replaced.
Why this works: the stream is scoped to a turn, not a tab
This is the hinge, and it is what makes serverless viable at all.
A socket that lives as long as a chat tab needs a process that lives as long as a chat tab. That fights scale-to-zero and cannot be reconciled. A stream that lives as long as one turn is a seconds-to-minutes request, which is exactly the shape a per-request runtime serves well.
Consequences we accept:
- A tab holding no active turn holds no connection. It learns about work finished by a routine through device push, or a cheap "anything after seq?" poll every 20 to 30 seconds. That was always the job of push; see the notify path in Messaging.
- A streaming function has a maximum duration (15 minutes on Lambda). A
turn can exceed it. The client reconnects with
after=seqand a fresh invocation resumes from the cursor.
That second point is load-bearing rather than a fallback: because the stream must be resumable, the buffer behind it must be a readable store with per-reader cursors. That requirement is what decides the rendezvous below.
Why server-sent events instead of a WebSocket
A WebSocket was previously chosen so approvals could travel back up the
same connection. Approvals are rare and human-initiated; an ordinary
POST serves them. Server-sent events are simpler through CloudFront,
avoid an upgrade-time authentication path, and reconnect natively with a
cursor, which is the after=seq replay we need anyway.
Dropping the WebSocket also removes the worker's outbound socket. The worker POSTs coalesced chunks to the same front door. Two persistent connections deleted, not one.
Coalescing
The worker does not emit one event per token. It flushes a chunk roughly every 250 milliseconds. This cuts event volume by more than an order of magnitude, makes the per-event cost of every option below negligible, and is invisible to a reader if the client renders each chunk smoothly across the following interval.
Any per-event cost argument in this document assumes chunks, not tokens.
The rendezvous: why DynamoDB and not a delivery service
With both ends ephemeral, the worker's chunks and the browser's stream have no shared memory to meet in. An always-on process gets this for free; that is the real thing serverless costs us here.
| fan out to N viewers | replay after reconnect | ordering | delivers to a waiting reader | zero at idle | |
|---|---|---|---|---|---|
| EventBridge | yes | no | no | no | yes |
| SQS | no (consumes) | no | FIFO only | yes (long poll) | yes |
| DynamoDB, polled | yes | yes | yes (sort key) | no (poll) | yes |
EventBridge: rejected on shape. It routes an event to a target, and a function target means a new invocation. There is no receive API and no way to deliver into the invocation already holding the stream. It would have to land in a store anyway, so it is strictly an extra hop. Delivery is also unordered, which garbles a token stream unless every reader re-sorts by sequence, which requires the sequence, which requires the store. Cost is not the objection: at chunk rates it is a fraction of a cent per turn.
SQS: rejected on shape. A queue is a work queue, not a fan-out. It destroys a message on consume, so two tabs, or a laptop and a phone, would steal each other's chunks. Fixing that means a topic plus one queue per viewer, with queue lifecycle per viewer. Worse, a consumed queue cannot be re-read, and the 15-minute reconnect above requires re-reading.
Kinesis: rejected on cost. A shard has an hourly floor, roughly 11 dollars a month for one shard. It breaks zero on its own.
AppSync: rejected on cost, and now on shape. The original arithmetic was per token and does not survive coalescing; at chunk rates it would be affordable. It is rejected now because it is a managed WebSocket, and this design has no WebSockets in it.
The cost objection is not hypothetical: we have paid large AppSync bills. That is real evidence and it outranks any arithmetic here. The lesson it carries is narrow and worth keeping straight -- a metered transport bills for how much the product is used, not for how much capacity it needs, which is the same failure mode as an idle floor seen from the other end. See "Why 'no sockets' is a cost rule" above.
DynamoDB loses exactly one column in that table, the waiting reader, and pays for it with up to 250 milliseconds of jitter that the client smooths over. The other two lose columns that cannot be bought back.
Polling is the price of not holding a socket, and it is cheap. The earlier objection ("polling the message store is too slow and wasteful for tokens") was about the browser polling. A function inside AWS querying one partition by sort key every 250 milliseconds is a different cost and latency profile entirely: fractions of a cent per turn.
The invariant that keeps this safe
The stream is ephemeral and fully re-derivable from the store. The work is durable and never depends on anyone watching.
A turn runs to completion whether or not a browser is attached. The
stream is a view, never a participant. Hold this and scale-to-zero
cannot cost correctness. Break it once, by letting the stream carry
state not recoverable through after=seq, and every reconnect becomes a
bug.
This invariant is also what preserves the product promise that closing the laptop does not stop work.
Why the spin-up cost is acceptable
The computer container already cold-starts: image pull plus Chromium
boot, tens of seconds. A function cold start of about a second is noise
against that. Under prefer_local, when the garage Mac is on the worker
is already warm and the only cold thing is the front door. We are not
adding latency to the critical path; the critical path was always the
computer.
The interface should say so honestly. "Starting your computer" is a real state, not dead air.
Why we chose this over one small always-on process
An always-on container plus a small managed database is roughly 25 to 30 dollars a month, and it is genuinely simpler: the rendezvous is a variable in memory and there is no polling seam.
The saving alone does not justify the extra moving parts. The reason is the multi-tenant seam (requirement 11), which is a real commitment rather than a door left open. A per-request control plane goes from one household to many at near-linear marginal cost, with no capacity planning and no idle floor per tenant. An always-on process multiplies that floor by the number of tenants, or forces tenants to share one process and take on the isolation problem that avoids.
Adopt this as a principle -- nothing bills while nobody is working -- or not at all. As a cost optimization it is marginal, and the cost framing will lose the argument the first time the polling seam is annoying.
Note what this argument does not claim. It is not that the system must handle high load; see non-requirement 4. One household running a handful of concurrent turns is the actual v1 workload, and the design should stay boring at that size.
Decided: same-origin CloudFront front door
Per-request front door: Lambda function URL behind CloudFront (as
today). How the product app reaches it: same origin — one CloudFront
distribution per environment hostname serves Next.js from S3 on the
default behavior and proxies /api/* to the function URL (prefix
stripped at the edge). No separate api.chattic.us subdomain.
Decided: worker bearer credentials
Workers register at POST /orgs/{tenant_id}/workers/register and receive a
one-time bearer token. The control plane stores only a SHA-256 hash.
Worker routes require Authorization: Bearer <token>. The shared
X-Chatticus-Invoke-Key remains a CloudFront-to-Lambda gate and is not
caller identity.
Still open
Placement details, which are configuration rather than architecture:
- TLS and session handling on the stream request.
- Whether local
docker-composeruns a single process standing in for the front door while developing.
2. Storing messages
Decided: DynamoDB. Not a relational database.
Confirmed against the channel model. This was chosen before challenge
4 was settled, which was the wrong order. Re-checked afterwards, it
holds: a channel is one append-only stream keyed by (channel_id, seq),
which is a native partition-and-sort fit. Had channels turned out to be
rooms containing per-bot threads, the merged-view reads would have argued
for relational instead.
Why this was not an independent decision
A relational instance is always on and has a monthly floor. Keeping one would break "nothing bills while nobody is working" no matter how serverless the compute is: the database, not the compute, is what silently keeps the meter running.
So challenge 1 largely decided challenge 2. Recording that coupling matters: if the scale-to-zero principle is ever abandoned, this choice should be revisited rather than inherited.
Why it is a good fit anyway, not just a forced one
The transcript is an append-only stream keyed by channel and a monotonic sequence, read as "everything after seq". That is a partition key, a sort key, and a range query. Compaction by appending a summary is the same pattern again: one query backwards to the latest summary, one forwards for the tail.
The parts that feel relational -- bots, approval rules, routines -- are small, low-traffic, and single-household. Ad-hoc joins across them are where a relational store would earn its keep, and for one household there are not many.
One store, two lifetimes
In-flight chunks and committed messages are the same technology, differing only by TTL:
| Item | Lifetime | Written by |
|---|---|---|
| Turn chunk | TTL, hours | Worker, through the front door |
| Committed message | Permanent | Control plane, at turn.completed |
Live tail and history replay become nearly the same query shape against the same key structure. This rhymes with compaction in challenge 3: one stream, two read recipes; here, one store, two lifetimes.
Keep the two item types distinguishable -- a separate item type, or an adjacent table -- so "messages are immutable and permanent" stays a clean invariant instead of one with an asterisk about the rows that vanish.
Shape
- Partition key
tenant#channel_id, sort keyseq. Tenant belongs inside the key so isolation is structural rather than a filter. - Compaction summaries are items in the same partition, found by
querying backwards for the most recent
summary. - Turn chunks live in their own partition keyed by turn, with the TTL.
Two indexes at least:
| Index | Serves |
|---|---|
(bot_id, time) |
everything one bot did, across channels |
(user_id, last_activity) |
the channel list, most recent first |
Full-text search across transcripts is out of v1. DynamoDB will not do it. Accept that, or plan a search index later, but decide it here rather than discovering it.
Still open
- Whether chunks share a table with messages or sit beside them.
- Whether bots, approval rules, and routines share the table or get their own.
- Screenshot and attachment handling: S3 objects referenced from the transcript, never bytes through the API.
3. Compacting conversations
Working model stands: non-destructive compaction on an immutable stream. A compact is another append, never a rewrite.
- Messages are an immutable stream. Once written, a message is not edited or deleted. The store only appends.
- Compaction appends a summary message to that stream. The summary
is a normal row (a kind such as
summary). It records which prefix it covers (for examplecovers_through_seq). - The original messages stay. Nothing is rewritten. The UI can still show the full stream.
- The compacted history for a model call is: the latest summary
plus every message after it (seq greater than
covers_through_seq). If there is no summary yet, the compacted history is the whole stream (or a bounded tail until the first compact). - Compaction can run anytime, asynchronously. It does not lock the channel. New messages that land while a compact job is running have a higher seq than the prefix being summarized, so they appear in the tail automatically. A later compact can cover a longer prefix, including earlier summaries, by appending a newer summary.
[m1][m2][m3][m4][S covers 1-4][m5][m6]
model view: S + m5 + m6
human scroll: m1 .. m6
That is one stream, two read recipes, not two stores. Do not add a second compacted table that can drift from the log.
Still open
- Who writes the summary (a dedicated turn, the control plane, a nightly job).
- When to compact (token budget, message count, idle time).
- What a summary contains (prose, structured facts, both).
- How a summary participates in the next compact. Because the model view is "latest summary plus tail", every new summary is built from a previous summary plus messages, so summaries compound, and they compound lossily. This is the question that decides whether the model holds up over months. Answer it before the store lands, not after.
- Whether summaries are visible in the human scroll or collapsed.
Compaction is non-destructive: append a summary, never rewrite the stream. Do not implement a summarizer loop until the store exists.
4. Channels and bot-to-bot
Decided. A channel is the conversation. Bots are participants in it.
The question that had to be answered first
Context compounds per bot. A conversation has several bots in it. So whose context is a channel? Nothing in the product docs answered that, and the storage schema is downstream of it.
The answer is that these are two different things, composed at turn start:
A bot's model input is its own memory plus the channel's compacted view.
- A channel is a shared append-only stream. Compacted once, per channel, serving every participant.
- Bot memory is per-bot and cross-channel: durable facts and preferences, not a conversation.
That also answers the cost objection to a shared stream. A bot does not receive raw everything; it receives the compacted view plus its own memory.
The model
- A channel is the thread. There is no separate thread object. A 1:1 conversation with one bot is a channel with one bot participant, not a special case.
- A channel has a
tenant_id, an owner user, a participant set (the human and one or more of that user's bots), and a monotonicseq. addressed_to_bot_idenqueues a turn for that bot. The same mechanism serves human-to-bot and bot-to-bot. There is no second bus.- Every bot reads the whole channel; only the addressed bot acts. A bot deciding to speak unaddressed is a later question.
- No private bot side-channels. The product promises the human can watch that work.
- Files stay on the computer. A message names a path under
/workspace; it does not copy bytes.
Why not a room containing per-bot threads
That shape gives each bot a cheap, clean context. It also rebuilds the human as router in software: bot A does not see bot B's work, handoff becomes explicit copying, and the human's merged view is a fiction something has to maintain. It breaks compaction too, which is per stream, forcing per-bot compaction over overlapping content.
Ordering is not open
The control plane assigns seq at commit, so order within a channel is
already total. What remains is two live turns interleaving in one open
tab, which wants per-turn lanes in the web app. It does not constrain
the store.
Bots must not HTTP-call each other. Workers pull jobs. That holds regardless.
5. Summoning the computer
Requirement 16 is decided. The approach below is the direction; the mechanism is specified and some details are open.
Measured 2026-08-31 (Test 2). Five sequential cold Fargate ARM64
RunTasks of the current computer image (no Chromium). Time from submit
to ECS RUNNING was 17.7–38.5 s (median 22 s). Time until Python
had written /workspace and packed a smoke snapshot to S3 was
20.1–40.8 s (median 22 s). Later runs were not uniformly faster;
run 3 was the slowest. Local Docker was not comparable here (daemon
unavailable). Chromium was not in that image, so browser-ready bands were
not applied on that pass. Requirement 16’s “model-call ready in seconds”
holds for the image family. Recorded in
spikes/computer-cold-start/results.
Do not optimize on the back of this number (non-requirement 3).
Measured 2026-08-31 (summoned browser gate, chatticus-d68966). Five
sequential cold summoned RunTasks on the Chromium :dev image with
CHATTICUS_COMPUTER_BOOT=1 and a one-shot
ComputerHostBootDriver().boot_through_browser() override (in-memory
ControlPlane; no SQS or DynamoDB). Time from submit to ECS RUNNING
was 25.9–28.4 s (median 26 s). Time until the first CloudWatch
browser_gate_ready: marker (Chromium version line from
verify_chromium_available) was 25.9–29.8 s (median 26 s). Browser
gate is typically within ~1 s of RUNNING on this path. Compared to the
pre-Chromium e747d7 RUNNING median (~22 s), the median rose ~4 s with the
larger image; run 2 was the slowest on browser gate. Recorded in
spikes/computer-cold-start/results.
Do not optimize on the back of this number (non-requirement 3).
Two problems share one answer. A bot must start talking immediately (requirement 16). And the computer, which is the expensive resource, should run only when a turn genuinely needs it.
The prize is duty cycle, not cold start
Chatticus prefers structured tools over the browser. So a large share of turns never touch a computer at all: answering from memory, summarizing a thread, drafting in the user's voice, a routine that reads an API through a connector. Booting a browser container for those is waste, and unlike a cold start, which is paid once, it recurs on every turn forever.
Rough shape, to re-verify before it carries a decision:
| Host for a 2-minute turn | Approximate cost |
|---|---|
| Computer container, including the image pull | ~$0.005 |
| Computerless worker doing 20 seconds of reasoning | ~$0.0002 |
The absolute saving is small for one household. Two things make it matter anyway. It scales linearly with tenants (requirement 11). And without it, scale-to-zero is defeated by trivial turns: if every "thanks, that will do" spins up a browser container, the expensive thing is never actually idle and requirement 7 buys nothing.
A computerless worker is still a worker
The pre-computer phase does not run on the control plane. It runs on a
worker that has capability cpu and not computer. It registers,
heartbeats, pulls a job, runs the model loop, and posts chunks like any
other worker. Requirement 13 is untouched.
The protocol already carries this. A turn job declares required capabilities; workers advertise capabilities and a cost class. A computerless worker is a narrower capability set and another cost class, not a new concept.
This also means the standing "no agent loop on Lambda" rule does not need relaxing -- it needs its premise stated. The rule exists because Lambda cannot hold a browser, a display, or computer use. A phase that does none of those does not touch the premise. Re-derive rather than cite, exactly as with the socket rule.
Readiness is per-capability, not one flag
Whatever host a turn is on, a single "computer ready" barrier in front of the agent is the mistake to avoid. The container has independent gates, and a turn blocks only on the one it needs:
| Gate | Needed for | Ready after |
|---|---|---|
| Process and network | Model calls, memory, MCP and connector tools | tens of seconds on cold Fargate for the current image (median ~22 s to RUNNING) |
/workspace hydrated |
File actions | empty workspace + smoke pack in the same tens of seconds; snapshot hydrate from S3 not separately timed |
| Browser profile hydrated, display and Chromium up | Browser actions | tens of seconds on cold summoned Fargate for the current Chromium image (median ~26 s to browser_gate_ready:; typically within ~1 s of RUNNING) |
| Watch and takeover surface | A human watching or taking over | last |
chatticus-agent starts the model loop as soon as the first row is
satisfied. Hydration and browser startup run in parallel with the opening
model call.
Three ways to summon the computer
The same mechanism, entered from three places:
| Path | Who decides | When boot starts |
|---|---|---|
| Declared at enqueue | Human, routine, or a calling bot | Before the first model call. Fastest. |
start_computer tool |
The agent, having read the request | One model round-trip in, overlapping its own reasoning |
| Implicit escalation | Nobody. Fallback. | At the first computer action, serialized |
Correctness must never depend on the model calling the tool. Touching
any computer tool escalates on its own. An agent that never calls
start_computer is slower, never wrong. The tool is an optimization
layered over a mechanism that works without it.
Declared at enqueue reuses the existing field: the turn job names
computer in its required capabilities, which routes it to a
computer-capable host from the start. This is not a new parameter. It is
declaring at enqueue what would otherwise be discovered mid-turn. A
morning routine that always drives a website should declare it and skip
the discovery round-trip; a human who knows the work is on a website can
say so; a bot handing off work knows what it is handing off.
The start_computer tool is:
- Non-blocking. It returns
starting,ready, orunavailableand the agent keeps working. Only an actual computer action waits. - Idempotent. Safe to call speculatively, twice, or when a warm local
Mac already serves the workplace. Then it is a no-op reporting
ready. - Policy-bound. Under
local_onlywith the Mac off it returnsunavailablerather than quietly starting Fargate.computer_policyalready carries this; the tool does not get its own policy. - Visible. The call appears in the stream, so "why did this turn cost money" has an answer a human can read.
There is no stop_computer. One computer per user is shared by every
bot on that user, so a bot stopping it could strand another bot
mid-task. Idle-down is a platform concern, not an agent decision.
Escalation
When a turn on a computerless worker reaches a computer action:
- The host appends the tool call to the turn's stream.
- It enqueues a job for the same turn with
computerin required capabilities and the user'scomputer_idpin. - It stops. It does not wait, and it transfers no state.
- A computer-capable worker pulls that job, reads the stream, executes, appends the result, and continues the loop.
The stream is the handoff. An agent loop's state is its message list,
and Chatticus already commits that list as an immutable append-only
stream with tool calls and results as rows. So a turn is portable across
hosts up to its first computer action. After one, it is pinned: a live
page, a shell's working directory, and running processes are host-local.
That pin is the existing computer_id pin, not a new concept.
This is a payoff from challenge 1. Because the chunk buffer is a store keyed by turn rather than a delivery path to a subscriber, two hosts can append chunks to one turn and the browser's stream neither knows nor cares.
The computer tools must still be present in the tool list on a computerless worker, or the model never asks for one and never escalates. Presence means "escalate", not "execute". The model does not need to know the difference.
One loop, one package. The tool registry differs by host capability, which the architecture already describes as a dynamic tool list. This is not a second implementation and must not become one.
Say what is happening
"Starting your computer" is a real state and belongs in the turn stream
as turn.waiting, naming the gate. A human who can see why a turn is
waiting will accept a wait that is otherwise indistinguishable from a
hang. See the event table in Messaging.
What remains cold
Image pull, paid before any gate above. Do not attack it yet (non-requirement 3). When it is worth attacking, the levers are a smaller image, lazy image loading, or a warm host during active hours, in that order.
Two things already blunt it. Under prefer_local a garage Mac that is on
is already warm, so the cold path is the exception. And the opening model
call is doing useful work while the pull runs.
Still open
- Whether starting an AWS computer needs a spend control beyond
computer_policy. It is not an approval-class action under the product's definition (nothing is sent, published, purchased, or deleted), but it does spend money on a bot's own initiative. Policy rather than an approval prompt is the likely answer; gating every boot behind a human would defeat the point. - Whether a bot's recent behavior should speculatively declare
computerat enqueue, and how that interacts with a wrong guess. - Which cost class a computerless worker takes, and where it ranks. It is
cheaper than every computer host, so ranking is not the hard part;
naming is. See the note on
prefer_localbelow. - Whether
unavailableunderlocal_onlyshould hold the turn until the Mac returns, or fail it back to the human.
prefer_local is misnamed
The policy means "prefer already-warm and cheapest first". Locality is a proxy for that, not the goal. A computerless worker is not local and should rank ahead of every computer host; a warm pool on hardware that exists anyway is not local either and should rank like it. The name encodes an assumption that a third host breaks. Rename it before there is a third host rather than after.
Other substrates plug in here
The worker protocol is substrate-agnostic by construction: workers advertise capabilities and a cost class, then pull. Nothing in the token path, the store, or the scheduler cares what kind of machine answers. Adding a warm pool -- Kubernetes, Nomad, a box that is on anyway -- means adding a cost class and its rank, not changing the architecture.
The caution is requirement 7. A cluster bought for Chatticus is an idle floor and is exactly what this design rejects. A cluster that exists anyway for other reasons has no marginal cost here, so do not cite requirement 7 at it; re-derive, and the answer legitimately differs. The garage Mac already passes that test and is the warm-pool answer v1 actually ships.
How to work on this
- Challenges 1, 2, and 4 are decided. Messaging describes that design rather than sketching options. Build against it.
- Challenge 3 (compaction) has a working model with open sub-questions: who writes the summary, when to compact, what a summary contains, and how summaries compound. The "bot deciding to speak unaddressed" question under challenge 4 is also still open. Discuss those here and in chat before encoding them in Gherkin.
- The messaging kernel in
python/src/chatticus/andfeatures/messages.featurepredate these decisions. They encode a transport-agnostic protocol that survives, but they are not yet the DynamoDB and server-sent-events design. Do not treat them as either the schema or the transport. - No persistent sockets. If a proposal needs one, it is wrong for this architecture; say so and find the turn-scoped version.
- Every new rejection states whether it is shape or cost, and a cost rejection states its assumption.
- Before arguing for a change, check it against Requirements and Non-requirements above. Most proposals that feel necessary are serving a non-requirement.
- Do not name other vendors' agent products in Chatticus docs, bots, or protocol types.
- Computers, snapshots, and prefer-local routing are a different layer. They keep moving independently.