Skip to content

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

Terminal window
maat new chat --maat-path=/path/to/maat-monorepo/packages/maat
cd chat
dart pub get
maat 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

Terminal window
maat make:auth
Created: database/migrations/create_users_table.dart
Created: lib/app/models/user.dart
Created: lib/app/http/controllers/auth_controller.dart
Created: lib/app/http/controllers/profile_controller.dart
Created: routes/auth.dart
Created: resources/views/auth/login.khnum.html
Created: resources/views/auth/register.khnum.html
Created: 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 migrate

Eight 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

Terminal window
maat horus:install
Published: public/js/horus.js

This 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

Terminal window
maat migrate
maat tailwind
maat thoth:start # one terminal
dart run bin/server.dart # another terminal

Register 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

Terminal window
dart test # 30 passing, examples/chat
cd test/browser && npm install && npx playwright test

The 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).