Messaging
This describes the decided design for the transcript and the streaming path. The reasoning behind each choice, including what was rejected and why, is in Design challenges. The channel model is settled there; this describes it.
Chattic.us is a conversation surface. The control plane is the only thing that writes the transcript and the only thing the browser talks to. Workers never notify the web app. Bots never HTTP-call each other.
There are no persistent sockets in Chatticus. Not from the browser, not from the worker. Everything is a request, and the longest-lived request is one turn.
Message store
DynamoDB is the source of truth for conversations. S3 holds blobs (screenshots, attachments). The computer snapshot is not the chat log.
A channel is one conversation. It belongs to one tenant_id and one
user. Participants are that human and one or more of that user's bots.
Messages are append-only. Each channel has a monotonically increasing
seq, assigned by the control plane at commit, which makes order within
a channel total. Clients reconnect with
GET /channels/{channel_id}/messages?after=seq. Edits and deletes are out
of scope for v1.
| Field | Role |
|---|---|
tenant_id |
Isolation. Required on every item. |
seq |
Per-channel order. Replay cursor. |
author_kind |
human or bot |
author_id |
user_id or bot_id |
body |
Committed text. Not a token. |
addressed_to_bot_id |
If set, the control plane enqueues a turn for that bot |
Files stay on the shared computer. A message may name a path under
/workspace. It does not copy the file into the transcript.
A bot's input is memory plus the channel
A channel is shared; bot memory is not. They compose at turn start:
A bot's model input is its own memory plus the channel's compacted view.
Every bot on a channel reads the whole channel. Only the addressed bot acts. Bot memory is per-bot and spans channels; the channel is compacted once and serves every participant. See challenge 4 in Design challenges.
In-flight chunks live in the same store, with a TTL
A turn's partial output is not a message. It is a short-lived item keyed
by turn and sequence, carrying a TTL of hours, written by the worker and
read by whatever is streaming. At turn.completed the control plane
commits one message row with the joined text; the chunks then expire
on their own.
| Item | Lifetime | Written by |
|---|---|---|
| Turn chunk | TTL, hours | Worker, through the front door |
| Committed message | Permanent | Control plane, at turn.completed |
Keep the two item types distinguishable so "messages are immutable and permanent" has no asterisk. Do not insert a message row per token.
Bot to bot
There is one message table. Bot-to-bot is not a second bus, queue, or protocol.
- A bot posts a message in a channel the human can already see.
- The message is addressed to another bot on that channel.
- The control plane enqueues a turn for the recipient, same as a human message would.
- The recipient's worker pulls the job. It does not receive an inbound HTTP call from the other bot.
The human is not the router. The human still sees the same channel.
The cloud API
Nothing bills while nobody is working. The API is per-request, and the only thing that lives longer than a request is a turn.
sequenceDiagram
participant Browser
participant FD as Front door<br/>(per-request)
participant CS as Chunk store<br/>(DynamoDB TTL)
participant TQ as Turn queue<br/>(SQS)
participant W as Computerless worker
Browser->>FD: POST /channels/{id}/messages
FD->>TQ: enqueue turn
FD-->>Browser: turn_id
Browser->>FD: GET /turns/{id}/stream (SSE)
Note over FD: streaming path may be<br/>function URL + CloudFront
TQ->>W: pull job
W->>W: run model loop
W->>FD: POST chunks
FD->>CS: write chunk items (TTL)
loop poll after cursor
FD->>CS: read chunks
FD-->>Browser: SSE frames
end
The front door bills per request and has no hourly floor: an API Gateway HTTP API, or a function URL behind CloudFront. Never a load balancer. An ALB's hourly floor costs more than the always-on container this design exists to avoid.
How the web app is notified
- The browser POSTs a message and gets back a
turn_id. - It opens
GET /turns/{turn_id}/streamand reads server-sent events. - The worker pulls the job, runs the model loop, and POSTs coalesced chunks (roughly every 250 milliseconds, not one per token). It starts the model loop as soon as it has network and memory; it does not wait for a display or a hydrated workspace it may never use.
- The streaming function polls for chunks after its cursor and writes them out as events.
- On
turn.completedone message row is committed. The client reloads or reconciles withGET .../messages?after=<seq>.
The stream is scoped to one turn, not to the tab. Between turns the browser holds nothing open, which is what lets the whole system reach zero. A tab with no active turn learns about work finished by a routine through device push, or a cheap "anything after seq?" poll every 20 to 30 seconds. Push cannot carry a token stream; it is only "come back, something finished".
Streaming functions have a maximum duration (15 minutes on Lambda). A
longer turn ends the stream; the client reconnects with Last-Event-ID
and a fresh invocation resumes. Reconnect is a normal event, not an error
path.
The client should render each chunk smoothly across the following interval. The up-to-250-millisecond delivery jitter is then invisible.
Approvals travel by POST
Approvals are rare and human-initiated, so they do not need a duplex
connection. approval.required arrives on the stream (or on the next
poll); the human's decision is an ordinary POST. This is why
server-sent events are sufficient and a WebSocket is not needed.
Events
| Kind | When | Stored as a message? |
|---|---|---|
channel.message.created |
A row is committed | yes, that row |
turn.started |
A bot turn begins streaming | no |
turn.waiting |
The turn is blocked on a readiness gate, naming which (for example a computer still booting) | no |
turn.token |
One coalesced chunk | no |
model.request |
A fenced attempt issued a model call | no |
tool.call |
A tool request was committed, keyed by action id | no |
tool.result |
A tool result was committed for that action id | no |
attempt.claimed |
A worker became the fenced owner of the turn | no |
attempt.relinquished |
The previous owner dropped the fence before continuation | no |
turn.completed |
Chunks are joined into one row | yes, one row |
approval.required |
A proposed action is blocked | the proposal, not the action |
Waiting is a state, not dead air
A cold computer delays a bot's first computer action. It must not
delay the bot's first word. When a turn does block on a readiness
gate, it emits turn.waiting naming what it is waiting for, so the web
app can show "starting your computer" rather than a spinner that is
indistinguishable from a hang.
See challenge 5 in Design challenges.
The invariant
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. If the stream ever carries state
that cannot be recovered through Last-Event-ID, reconnect becomes a bug
and the guarantee that closing the laptop does not stop work is gone.
What the cloud API is not
- No WebSocket, browser-side or worker-side. A socket that lives as long as a chat tab needs a process that lives as long as a chat tab, which is the thing we are avoiding.
- No load balancer. Hourly floor, no idle scaling.
- No EventBridge or SQS in the token path. Both are the wrong shape: EventBridge cannot deliver into the invocation already holding the stream, and SQS consumes on delivery so it can neither fan out to two viewers nor be re-read on reconnect. See Design challenges for the full comparison.
- No relational instance. An always-on database would keep the meter running no matter how serverless the compute is.
EventBridge is right for coarse, per-turn signals where a second of
latency is invisible: routine wake-ups, starting a worker, and
turn.completed fanning out to device push. The line is: route what you
would wait a second for; buffer what you are rendering live.
HTTP surface
| Path | Use |
|---|---|
GET /health |
Liveness; names the cloud environment (development, staging, production, or local) |
POST /bots |
Create a named bot. Retry with the same Idempotency-Key header returns the original bot |
GET /bots?user_id=&name= |
Look up a named bot after recycle |
GET /users/{user_id}/bots |
List a household user's named bots after recycle |
GET /users/{user_id}/channels |
List a household user's channels after recycle |
GET /users/{user_id}/computer |
Read the household computer (id, stopped, policy) after recycle |
GET /users/{user_id}/turns |
List a household user's in-flight turns after recycle |
GET /bots/{id} |
Read a bot, including isolated memory |
POST /bots/{id}/memory |
Persist one bot memory item |
POST /channels |
Open a channel. Retry with the same Idempotency-Key header returns the original channel |
GET /channels/{id} |
Read a channel record after recycle |
GET /channels/{id}/turn |
Read the active turn on a channel after recycle, including waiting_for when gated; 404 when none is active |
POST /channels/{id}/messages |
Human (or bot) commits a message; returns turn_id if a turn starts. Retry with the same Idempotency-Key header does not duplicate the message or enqueue a second turn |
GET /channels/{id}/messages?after=<seq> |
History and reconnect |
GET /turns/{id} |
Read a turn after recycle, including waiting_for when gated |
GET /turns/{id}/events?after=<seq> |
Durable turn journal after a seq |
GET /turns/{id}/stream |
Server-sent events for one turn (Last-Event-ID) |
POST /turns/{id}/chunks |
Worker appends coalesced output |
POST /approvals/{id} |
Human decides a blocked action |
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
These are placement and configuration, not architecture:
- TLS and session handling on the stream request.
- Whether local
docker-composeruns one process standing in for the front door while developing. - The DynamoDB key structure, and whether chunks share a table with messages. See Design challenges.
Decided: same-origin front door
The browser reaches the thin-turn API at /api/* on the same host
as the Next.js app (e.g. https://hey.chattic.us/api/channels/... in
production). Each
named environment has one CloudFront distribution per web hostname:
- Default behavior — S3 origin for Next.js static assets.
/api/*behavior — Lambda function URL origin (caching disabled; same SSE settings as the thin-turn spike). CloudFront strips the/apiprefix before forwarding so FastAPI routes stay at/channels,/turns, and so on.
Workers and local acceptance may continue to call the function URL directly; the public browser surface uses same origin only.