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 |
turn.thinking |
One coalesced piece of the model's reasoning summary, when the model streams one | no |
tool.call |
A tool request was committed, keyed by the Pi call id, with sanitized arguments | no |
tool.result |
A tool result was committed for that call id, with a bounded output | no |
computer.starting |
The turn parked for the organization's computer | no |
computer.ready |
An owner claimed the turn after that park | 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 |
turn.failed |
The turn ended without an answer. body is a reason the member can read, for example that the model provider is out of quota |
no |
turn.reconciling |
The outcome is uncertain; the client reloads committed state | no |
approval.required |
A proposed action is blocked | the proposal, not the action |
What the activity events carry
Every event carries kind, seq, event_id, turn_id and channel_id. The events that describe what a bot is doing
add the fields below. Fields not listed for a kind are absent. An event stored before a field existed simply lacks it;
readers treat a missing field as absent.
| Kind | Fields |
|---|---|
tool.call |
tool (name), call_id (the Pi tool call id), summary, arguments (object), arguments_truncated (boolean) |
tool.result |
tool, call_id, ok (boolean), summary, output (text), output_truncated (boolean), duration_ms (number or null) |
turn.thinking |
text |
computer.starting, computer.ready |
detail |
tool.call.arguments is a sanitized copy, never the raw arguments:
| Tool | arguments |
summary |
|---|---|---|
run_terminal |
command (first 1000 characters) |
Running: <command, first 80 characters> |
read_workspace |
path |
Reading <path> |
write_workspace |
path, bytes (bytes of content), preview (first 200 characters) |
Writing <path> (<n> bytes) |
edit_workspace |
path, old_text and new_text (first 300 characters each) |
Editing <path> |
browse |
url |
Browsing <url> |
| anything else | first-level strings cut to 300 characters, numbers and booleans as they are, at most 10 of them, nested values dropped | Calling <tool> |
Paths and URLs are cut at 500 characters. arguments_truncated is true when anything was cut or dropped.
tool.result.summary is Exit <n> for run_terminal (ok is true only for exit 0), Edited <n> block for a
successful edit_workspace, Wrote <n> bytes for a successful write_workspace, Done for any other success, and
Error: <first 80 characters> for a failure. output is the result text whole up to 3000 characters; a longer one keeps
its first 1500 and its last 1500 characters joined by \n...[N characters omitted]...\n and sets output_truncated.
duration_ms is measured by the attempt that saw the call start, so it is null when a call began under an earlier
attempt. call_id links a result to its call, and a call is journaled once however many attempts replay it.
turn.thinking is emitted only when the model streams a reasoning summary; nothing is invented when it does not. The
pieces are coalesced like turn.token and the turn carries at most 2000 characters of them in all.
computer.starting (detail: Starting your computer) is written when the turn parks for the computer, just before
turn.waiting. computer.ready (detail: Computer ready) is written by the first attempt that claims the turn after
that, right after its attempt.claimed.
Before any argument or output is placed in an event, every value in the process environment whose name is
CHATTICUS_MODEL_GATEWAY_TOKEN, CHATTICUS_OWNER_HOST_TOKEN, AWS_SECRET_ACCESS_KEY or AWS_SESSION_TOKEN, or contains
KEY, TOKEN or SECRET (values of at least 8 characters), is replaced with [redacted]. Each event stays well under
20 KB, far below the 400 KB item limit.
Tool activity in the message list
GET /orgs/{tenant}/channels/{id}/messages gives every bot message an optional activity array: the tool calls the bot
made between the prompt and that answer, so the tool cards survive a reload. It is built from the committed Pi session,
which is permanent, not from the 24 hour events table, and the read-only Front Door builds it with no writes.
An item is the tool.call and the tool.result of one call in one object: call_id, tool, summary, arguments,
arguments_truncated, ok (boolean, or null when the session recorded no result), result_summary (the result's
summary, or null), output (text or null), output_truncated and duration_ms. Arguments and output come from the
same functions as the live events (tool-event-fields.ts), so the sanitising, the cuts and the secret scrub are the
same, and an item says what the live events of that call said. duration_ms is always null because the session stores
no timing.
Rules:
- The activity is attached to the bot's final answer of the turn, the message the channel log points at. It covers every
call made after the bot's previous answer in its own session up to that answer, in call order, text-before-tool steps
and steered messages included. A message with no tool calls has no
activityfield; human messages never have one. A turn that ended without any answer has no listed message of its own, so its calls are listed on the next answer. - At most 50 items per message. A longer turn keeps its first 49 and ends with
{tool: "more", call_id: "more", summary: "<N> more tool calls", ok: null, ...}where N counts the calls left out. - At most 3,000,000 characters of activity in one response. When a list would exceed that, the oldest messages give up
their activity first and the newest keep it, so the response stays far below the 6 MB Lambda limit however long the
history. Activity is computed only for the messages the page returns, and
afterandseqbehave as before.
A failed model call fails the turn at once
When the model provider answers with an error that a retry cannot fix (the
account is out of quota, the key is rejected, the request is invalid), the
worker ends the turn through the fenced POST /turns/{id}/failed with a
member-facing reason, and the queued job is dropped. Temporary errors (rate
limiting, an unavailable provider, a network fault, or an error response
without the provider's error body) leave the turn active; the queue retries
it once the worker's lease expires. See features/model_provider_failures.feature.
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 |
Computer files (read-only, from the published snapshot)
Under /orgs/{tenant_id}/computer/files, user-authenticated and checked like the
other organization routes: an enabled member of that organization, no other tenant.
The Front Door reads the computer's published snapshot (the manifest, the index it
names, and one ranged read of a file's gzip member) and writes nothing. Every
response carries X-Content-Type-Options: nosniff. Errors are
{ "error": code, "message": text }. Paths are API paths that must normalise under
/workspace (at most 1024 characters, no NUL, no ..); /workspace/src/a.c is the
index entry workspace/src/a.c.
| Request | Answer |
|---|---|
GET .../computer/files?path=/workspace[/dir] |
200 { source: "snapshot", as_of, path, truncated, entries: [{ name, path, kind, size, mode, sha256, link_target }] }. as_of is the manifest's published_at. Directories first, then by name, at most 2000 entries (truncated: true beyond). kind is file, directory or symlink; size and sha256 are set for files only, link_target for symlinks only, mode is octal permission bits like "644" |
GET .../computer/files/stat?path= |
200 { source, as_of, entry }; a symlink is shown as itself |
GET .../computer/files/content?path=&offset=0&length=262144 |
200 { source, as_of, path, size, sha256, encoding: "utf8"|"base64", binary, offset, length, content, truncated, masked } with ETag: "<sha256>". sha256 is of the whole file; length is the bytes returned (a text read never ends mid-character, so continue from offset + length). length over 262144 is 413 range_too_large. Binary (a NUL byte or invalid UTF-8 in the first 8 KiB) is base64. A secret-looking file (.env, .env.*, *.pem, *.key, id_rsa*, id_ed25519*, credentials, .netrc, .npmrc, *.p12, *.pfx, anything under .ssh/ or .aws/) is masked: true with content: ""; it is listed normally |
Errors: 400 invalid_path / invalid_range; 404 no_snapshot (the computer has never
published), 404 not_found; 409 snapshot_not_indexed (a snapshot made before
indexes existed); 413 range_too_large; 422 not_a_file (content of a directory),
422 not_a_directory, 422 too_many_symlinks; 500 corrupt_snapshot (the stored
bytes do not match the indexed SHA-256; no content is returned and an error line
snapshot_member_corrupt is logged); 503 snapshot_store_unavailable (no snapshot
bucket configured). A symlink is followed on a content read only inside the index,
at most 8 hops, only to targets under workspace/. Authentication refusals keep
the shape of the other organization routes (403 with detail).
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.
Registration is refused with 403 for an unknown or disabled organization, and for an
existing worker_id unless the request carries that worker's current bearer token.
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.