Build a Realtime Chat
examples/chat is a group and direct-message chat with live delivery, an
online roster, typing indicators, file attachments and emoji reactions. This
is the transcript: every command that built it, the honest line count of
every file a person wrote by hand, and — because a framework page that only
shows the wins is not trustworthy — the three real gaps the build found in
Maat itself and fixed along the way.
Every command below was re-run against a clean checkout before this page was published. If a command did not work as written, it is not on this page.
What you did not have to install
No Node, no npm, no package.json, no node_modules, no Redis, no Pusher
account, no separate WebSocket service. The one build step is
maat tailwind, which shells out to the Tailwind CSS v4 standalone binary —
not a Node toolchain.
The one honest exception: examples/chat/test/browser/ is a Playwright
suite, and Playwright is an npm package. That directory has its own
package.json and its own node_modules/, gitignored, exactly like any
other test-only tool dependency. The application itself — everything under
lib/, routes/, resources/, public/js/chat.js — has no npm dependency
anywhere. grep -r node_modules examples/chat --include=*.dart and
find examples/chat -name package.json both return nothing outside
test/browser/.
The application
Scaffold it
maat new chat --maat-path=/path/to/maat-monorepo/packages/maatcd chatdart pub getmaat key:generate--maat-path points the generated pubspec.yaml at a local checkout instead
of published packages — every fix this build needed (below) has landed on
main but not yet in a release, so a plain dart pub global activate ptah
today would not carry them.
About the maat command. dart pub global activate ptah puts maat on
your PATH. Outside a project it creates one (maat new); inside a project it
forwards whatever you give it to that application’s own console entrypoint,
bin/maat.dart (packages/ptah/bin/maat.dart). So maat make:auth and
dart run bin/maat.dart make:auth do the same thing, and every command below
is written the short way. If you are working from a checkout of the monorepo
without activating ptah, use the long form — that is the only difference.
Add session authentication
maat make:authCreated: database/migrations/create_users_table.dartCreated: lib/app/models/user.dartCreated: lib/app/http/controllers/auth_controller.dartCreated: lib/app/http/controllers/profile_controller.dartCreated: routes/auth.dartCreated: resources/views/auth/login.khnum.htmlCreated: resources/views/auth/register.khnum.htmlCreated: resources/views/auth/settings.khnum.html
Lines to add, in files make:auth does not touch: bootstrap/app.dart (withProviders list) AnubisServiceProvider.new, database/migrations.dart ...anubisMigrations, // creates anubis_sessions CreateUsersTable(), routes/web.dart authRoutes(); lib/app/providers/app_service_provider.dart Anubis.provider('users', (id) => User.query().find(int.tryParse(id) ?? -1));
Then: maat migrateEight files — a login form, a registration form, and an account-settings page
with a profile update and a password change that re-hashes with argon2id,
requires the current password, and signs out every other session on success.
None of it is chat-specific: this is what make:auth generates for any
application. Add the four printed lines by hand, then dart analyze is
clean and maat migrate creates users and
anubis_sessions. No password-reset flow, no email verification, no
two-factor — make:auth says so in the same doc that describes it
(Scaffolding Authentication),
and this example does not add them either.
Add the browser client
maat horus:installPublished: public/js/horus.jsThis requires two lines the command’s own doc page does not currently
mention: add horus_js: ^0.1.0 to pubspec.yaml and
.withExtensions([horusJsExtension]) to bootstrap/app.dart, next to the
existing .withConfig(...) call. Once wired, horus:install writes one
JavaScript file — no dependency, no build step — that speaks the same
Pusher-compatible protocol Thoth serves.
Write the schema
database/migrations/create_chat_tables.dart (54 lines) — conversations,
participants, messages; create_attachments_and_reactions.dart (34
lines) — attachments, reactions. The one design decision worth citing
verbatim, because it is what makes “two people clicking each other’s name at
the same moment” produce one room instead of two:
// The two participant ids, sorted and joined: '7:12'. Unique, so two// people opening a direct conversation at the same moment cannot// create two of them. Null for a group.table.string('direct_key', 40).nullable().unique();Write the models and the one service worth isolating
Five models, each following the same def/fromMap/toJson shape as the
User model make:auth generated: Conversation (77 lines), Participant
(72 lines), Message (110 lines), Attachment (68 lines), Reaction (55
lines).
lib/app/services/conversation_service.dart (89 lines) is the only piece of
logic pulled out of HTTP — opening a direct conversation, creating a group,
listing a user’s rooms, marking one read. Its openDirect is also where the
one real chat-specific bug this project shipped and then fixed lived (see
What the framework did for free below).
Write the two broadcast events
lib/app/events/message_sent.dart (24 lines) and
lib/app/events/reaction_toggled.dart (38 lines) — each broadcasts one
event on PrivateChannel('conversation.<id>'), payload is the model’s own
toJson().
Write the HTTP layer
Four controllers: ChatController (182 lines — renders the shell and the
first page of history), MessageController (136 lines — list, post,
mark-read), ConversationController (36 lines — create), ReactionController
(66 lines — toggle). MessageController.store’s validation is the entire
attachment contract:
final data = await request.validate({ 'body': 'required_without:attachments|string|max:4000', 'attachments': 'nullable|array|max:5', 'attachments.*': 'file|mimes:jpg,jpeg,png,gif,webp,pdf|max:$maxAttachmentKb',});routes/channels.dart (22 lines) is the whole authorization surface for both
realtime channels this application uses:
// Who may listen to one room: exactly its participants.Broadcast.channel('conversation.{id}', (user, params) async { final id = int.tryParse(params['id'] ?? ''); if (id == null) return false; return conversations.isParticipant(id, (user as User).id!);});
// One roster for the whole application. Returning a map (not `true`) is// what makes it a presence channel.Broadcast.channel('chat', (user, params) => (user as User).toJson());routes/api.dart (27 lines) and routes/web.dart (18 lines) wire the above
behind ['anubis.session', 'anubis.csrf', 'auth'].
Write the interface
Five Khnum templates under resources/views/chat/ — index (107 lines),
rail (111 lines), thread (162 lines), message (143 lines), members
(29 lines) — and one Alpine store, public/js/chat.js (476 lines), covering
send/receive, typing, presence, reactions, attachments, the Cmd+K room
switcher, and the off-canvas mobile layout. This is the largest hand-written
file in the application, and the only one with no framework equivalent to
lean on — Alpine and Tailwind are conventions, not generated code.
Bring it up
maat migratemaat tailwindmaat thoth:start # one terminaldart run bin/server.dart # another terminalRegister two accounts in two browsers, open a direct conversation from the rail, and a message sent in one appears in the other with no reload, exactly once on the sender’s own screen, while a typing bubble and a green presence dot track the other side live.
Prove it
dart test # 30 passing, examples/chatcd test/browser && npm install && npx playwright testThe Dart suite covers every route and the two /broadcasting/auth cases
(participant: 200, stranger: 403) against a real cookie-session guard. It
cannot prove that a second browser receives what the first one sends — for
that, test/browser/chat.spec.ts drives two real, independent browser
contexts against a temporary instance and asserts on delivery, typing,
presence, and that reactions and attachments both survive a hard refresh.
What the framework did for free
Claims here are checkable — file and line, as of this project’s final commit.
Presence rosters. /broadcasting/auth signs a presence subscription and
returns the caller’s own channel data
(packages/maat/lib/src/broadcasting/broadcast_service_provider.dart:85-133,
_authorize); Thoth assembles and returns the full member list on
subscribe, with no application code building or storing it
(packages/thoth_realtime/lib/src/thoth_handler.dart:363-378). The chat’s
only presence-specific code is the one-line channel registration quoted
above.
Typing indicators. A whisper is a client-to-client event that never
touches application code: Thoth relays any client-* frame straight to
every other subscriber of the same channel, gated on one boolean
(packages/thoth_realtime/lib/src/thoth_handler.dart:395-410). The only
change this application made was one configuration line —
examples/chat/config/thoth.dart:14, 'client_events': envBool(..., true) — because the framework default is false
(packages/thoth_realtime/lib/src/thoth_service_provider.dart:31). No
server route, no database write, no broadcast event.
Not receiving your own message. MessageController.store broadcasts
with .toOthers(request)
(examples/chat/lib/app/http/controllers/message_controller.dart:105),
which reads the X-Socket-ID header — attached to every outgoing request by
horus.fetch (packages/horus_js/lib/asset/horus.js:403) — and excludes
that socket from delivery. The sender renders its own message once, from
the HTTP response; the socket never echoes it back.
A subquery, not a round trip. ConversationService.forUser filters
conversations with whereIn('id', Participant.query().select([...])...),
passing a QueryBuilder — not a list of ids fetched first — directly to
whereIn (examples/chat/lib/app/services/conversation_service.dart:68-77).
Seshat compiles it as one nested SELECT ... IN (...), not two queries
(packages/seshat/lib/src/query/query_builder.dart:258-266,
packages/seshat/lib/src/database/grammar.dart:129-133).
A named exception, not a bare catch. direct_key’s unique index is what
guarantees two people opening a DM at the same instant get one room, not
two — but the loser of that race still has to survive its own INSERT
throwing. Seshat exposes UniqueConstraintException as its own type, distinct
from a generic database error
(packages/seshat/lib/src/exceptions/exceptions.dart:31), which is what let
ConversationService.openDirect catch exactly that condition and re-select
the winner’s row, instead of a catch (_) that would have hidden a real
schema violation too
(examples/chat/lib/app/services/conversation_service.dart:14-38, fixed in
56e1790).
What was missing when we started
This chat is the reason each of the following now exists. None of them were speculative additions — the build stalled on each one until it was fixed.
No browser client at all. Maat’s realtime story ended at the server: a
Dart or Flutter client could consume a typed stream, but nothing spoke the
protocol from a page. horus_js was built for this project —
3fde2a1 (connect and receive public events), ed633af (private and
presence channels), 976bb53 (whispers, reconnect, the socket id header) —
and packaged as maat horus:install in 4dd6ade.
No multipart parsing. Before 4611eeb, Request had no concept of an
uploaded file. Worse: Request.fromShelf ran every request body through
utf8.decode, unconditionally — so a JPEG’s first byte (0xFF, not valid
UTF-8) would have thrown before any parser saw it. That decode is now
skipped for a multipart body (8579f3f), file uploads validate with
file, image, mimes and a kilobyte-aware max (a508b71), and a
single file posted under an array-style field name (attachments[] with
exactly one file) correctly stays a list instead of collapsing to a scalar
(7971d3f) — the exact shape this chat’s own attachment validation needed.
/broadcasting/auth could not run middleware. The route was registered
with no middleware at all, so a session-authenticated application’s guard
read request.session before any middleware had started one — a 500 on
every private-channel subscription, not a 403. This chat is the first
example in the repository to combine cookie sessions with broadcasting,
which is why nothing had caught it before. Fixed in 32f6656: a
broadcasting.auth_middleware config key applies the application’s own
middleware (['anubis.session'] here) to that one route.
No authentication scaffolding, at all. maat new produced a welcome
page and a dashboard — no login form, no registration, nothing to
authenticate a second user with, which a chat cannot do without.
maat make:auth did not exist before this project. It now generates a
working login, registration, and account-settings flow
(7bffb1b, account settings added in 6aac3e8), including a fix for a
real timing oracle in the generated login handler found by code review
before this chat ever used it (b523783: an unknown email skipped the
argon2id check entirely, making a wrong password on a real account
measurably slower than one on an address that does not exist). Two
follow-on fixes made the command’s own output trustworthy: a fresh
application’s pubspec.yaml did not declare anubis, so the generated
code would not compile (34a04ec), and a fresh application’s console
kernel never registered make:auth in the first place — the command was
unreachable until ba2d9bb. A third fix closed the gap between what the
command generates and what it tells you to do: the printed wiring never
mentioned the migration that creates anubis_sessions, so an application
built by following it literally broke on its first request
(a093a2e).
Related
- Authentication —
make:authand what it deliberately does not build. - Broadcasting — channels, drivers, and authorizing them from a session-based application.
- Realtime in the Browser — the full Horus API.
examples/chat— the application this page describes.