I started working on this slightly amorphous community chat thing called Roomy 16 months ago when I met and . We had principles: we wanted it to be not only open source but also give all users meaningful agency over their own data.1
From then until March of this year, we worked from a baseline assumption that meaningful agency had to be local first, meaning the data had to primarily be on the user's device. As the two core engineers, Zicklag and I found out that building a reliable realtime chat app that meets both this criterion and our UX requirements is hard. We found it out by both working very hard, doing (we think) clever engineering, and still struggling with a very high number of browser-specific bugs and edge cases painfully slowing us down.
We were already using AT Protocol for identity, and our sync server architecture was also inspired by it. Over time, we also came to see ATProto's notion of a portable Personal Data Server (PDS) as providing decent user agency that's in principle fairly on-par with local first. The only problem was it didn't have an interoperable solution for private data.
When AtmosphereConf happened in March, it became clear that this was going to change imminently. With permissioned spaces on the horizon, we knew we needed to start again almost from scratch - get rid of the clever engineering, and radically simplify our project to get Roomy ATProto-native.
Why? Because the only real replacement for monopoly social media is an open, interoperable network.
A few weeks ago, we got something real: the alpha implementation of the new protocol, ready for testing. Over the last five months, I've rebuilt the frontend, our Discord bridge, created a new appserver and XRPC API, and with the newfound simplicity shipped a lot of features, all while striving to keep Roomy live and robust enough to host the communities who have joined.
Last week, we officially made our Discord bridge and paid subscriptions generally available for purchase, and as I shared a little more quietly, this also marked the point at which I no longer have a stable paycheck working on Roomy. With both our future as a company as well as my own career future at a particularly uncertain point, I wanted to write this article to show off work I'm proud of and share some of my thought process through various engineering and design decisions.
TL;DR: I owned a full appserver rewrite, redesigned core context-switching UI, designed an authZ model validated with formal methods, refactored our DB to prep for permissioned spaces, migrated our rich text schema, built a billing system, ran a 9-agent async dev loop, shipped push notifications and a bunch of other features end-to-end.
April: planning and first steps
Me and spend two weeks ideating about architecture based on what we knew about the new 'permissioned spaces' protocol: critically, it's a primitive. It doesn't have any concept of communities, roles, governance structures – essential for what we're doing. What we did have was something orthogonal: 's opensocial.community, a model for individuals to contribute to a shared data store on the public protocol.
Our v1 concept for interoperable communities decomposed read access from write access, and we named the corresponding services the arbiter and the steward. The former would allow composing permissioned spaces to enable role-based access control. This would be the key interoperability service, so we would focus on that. began a project to model the service in Quint and then implement in Rust.
In the meantime, I would work on actually-existing Roomy:
My @roomy.space roadmap for next few weeks: 1. Private spaces + invites 2. Permissioned channels + roles 3. Big refactor to AppView + XRPC interface / thin client 4. Push notifications all parallel to @zicklag.dev speccing and implementing an ‘arbiter’ RBAC service for ATProto permissioned spaces
'Next few weeks' was a bit ambitious for step 3 in particular
On April 22, I shipped private spaces, and a few days later I shipped permissioned channels and roles.
May: quick prototyping
Detour: rewrite the Discord Bridge
Before I got to properly implementing step 3 in my skeet-roadmap, I needed to rewrite our Discord bridge from scratch. The bridge is our main feature we've been planning to charge for, and it lets us bridge Muni Town, our main async workspace, which as an extremely remote team is pretty important. My v1 take on the bridge tried to make the bridge state deterministically reproducible from the combined data on Roomy and Discord. This doesn't work, because Discord isn't structured as an event log, which I realised thanks to feedback from .
The new design made better persistence assumptions, and also moved from Node.js to Bun. This was also a fairly bold agent-driven rewrite, and it took two weeks. It's since been much more reliable and performant.
Appserver Design
Next: the appserver.2 The concept was to take everything our heavy local-first-inspired client was doing (embedded SQLite DB that materialises an append-only log of events) and move it to the server. I started this with a spec of all the endpoints we wanted, and the starting place for this was the list of reactive queries that the heavy client was making against its local SQLite DB. The way we had it structured, SQLite was running in a worker, so the UI thread had to communicate with it by message passing: very much like having a local backend server.
I massaged that list a bit – consolidated it, to account for network hops being a factor now. New concept: everything is a simple XRPC query, which is a GET request, and it all ends up in an SWR cache on the frontend. Then we have a single WebSocket connection to the appserver which sends two things:
- 1.
a stream of incoming new messages, since we want that surfaced as quick as possible
- 2.
for anything else we just get cache invalidation signals: a simple message saying "refresh x endpoint because there's new data!"
Specs and Models
I knew I wanted the coding process for the appserver to be heavily agent-driven, more or less vibe coded, because moving fast was so high priority and a lot of it would likely need to be rewritten when we got to the permissioned spaces migration anyway. However I was concerned about correctness when it came to our new authZ features, so I decided to try modelling the access control logic using Quint and then use model-based testing to validate the agent's work.
Quint officially only supports Rust for model-based testing but it was pretty easy to vibe a Vitest harness for testing model traces against the code. Most of my work alongside this was refining a spec for the new XRPC API. Once this was done implementation was generally very easy.
Frontend
I validated the API design with a small 'playground' frontend with UI for testing all the endpoints, including the WebSocket/cache invalidation signals concept, which showed the appserver working alongside the existing client in staging:
Then I factored out as many UI components as I could to a common design package. This would be used for the existing Roomy alongside a new frontend package called app-lite, and then it was a speedrun to get feature parity. By the end of the month we merged this big PR to prod and started testing with production data.
June: design sprint
Looking back through my commits for this month I just see hundreds of little adjustments to styling, appserver optimisations, shipping little features, at some point we shipped the new client, and the old one was fully removed by the end of the month. I got into a real design mode this month. One feature I had planned for the new client was an experiment with a feed-like interface, which we've still got on the homepage.
We also did a lot of iteration on the sidebar design, ultimately I shipped a new design for the space switcher that was inspired by context-switching UI in Notion and Reddit. We spend a lot of time debating whether to still have a Discord-style server bar, with arguments made that this would improve our legibility as a Discord competitor. Here was one version exploring that:
My argument was that the sidebar switcher in Discord is visual noise that stimulates dopamine and distraction at the cost of focus and calmness. In a world where we're comfortably funded, we could validate or challenge our assumptions with user research, but ultimately we had to just make a call and keep moving, landing here:
The list of other things I worked on around this time is long and mundane but includes link embeds, design iteration on the 'index'/threads view, appserver backfill slowness issues, user profile and user settings pages, various caching improvements, listing sent reactions.
I've reflected a few times while working on Roomy that we're working on a product that in 2026 has a pretty high baseline for what counts as 'passable', feature-wise, compared with what would be genuinely new or innovative. It feels comparable to building a desktop publishing or spreadsheets tool: there is so much to build to make your thing even worth looking at compared to Google or Microsoft offerings. It's a marathon just to get to the starting line.
The obvious conclusion is that bootstrapping this kind of product without VC-level funding is a mistake. The only real counter to that that I see is that agentic development (done well) could help level the playing field. We've been grateful to have the very experienced sharing insights on how this can be done effectively with Chatto, and a lot of this year for me has also been about skilling up in these areas.
July: push notifications
In July we pulled out our beloved event-sourcing sync server Leaf, with its ambitions of federated sync soon to be made redundant by ATProto permissioned data. refactored the appserver to own event storage. I added a dashboard view for us admins to get a better sense of what's going on on the site.
Finally ticking off the last item on my skeet list, I implemented web push notifications, and took the opportunity to introduce a dynamic feature flags system for basic staged deployments.
The core concept for web push is pretty simple: you generate an asymmetric encryption keypair for securing between the app and the push provider; you ask the browser for an endpoint; you store that on the server, then encrypt, push to that endpoint, it shows up and is decrypted in the browser's service worker. When you click on the push, the service worker handles it, and this is how we get deep linking.
Similar to other areas where the spec is the hard part now, for this the slow part was UX design for users opting in to how they want to be notified, finding good defaults that balance between distractingly noisy and forgettably quiet. We will want to refine it further but I'm happy with where we landed for now.
There were various other things I did in July, but I was also visiting family in Europe (and going to DWeb camp!) up until mid-August, so it was a bit of a slower time.
August: appserver performance, richtext
I refactored the appserver to move space materialisation and queries into workers owning their own SQLite DBs. We have long planned to match our event sourcing architecture with an actor model, so splitting this out is a nice conceptual move towards that, but in practice there is a concern that the JS worker messaging may add more overhead than the benefit we get from concurrency. We have more to do to refine this design but at the time I got the agent to measure performance with 1, 2, 4 and 8 workers; we found diminishing returns after 4 so I went with that to start with.
As we've gone on (jumping around a bit here), I've incrementally been able to batch queries to reduce the round trips between the global DB on the main thread and the space worker, which has meant we could push up to 8 - ultimately I think the goal would be to make it one round trip per incoming write, and then split it out into a horizontally scalable service, like in the 'space actor scheduler' in this architecture I more recently proposed:
I also started introducing in-memory LRU caching in the appserver around this time.
A new richtext schema
As I was starting to ship new features in the chat view like link embeds and @mentions, we were getting cumulating issues with parsing and rendering rich text, especially across the Discord bridge. We've long debated as a team the merits and pitfalls of Markdown and other formats for representing rich text, and my general stance has been that while Markdown is nice as a human-readable archival format for text-centric content, it's also imprecise and lossy when it comes to any nonstandard features. We already use a Prosemirror-based text editor, and generally I think Prosemirror made really smart decisions in how they represent rich text, e.g. marks being properties on the flat text node list helps avoid resolution complexity from overlapping marks and gives you a canonical representation.
One obvious alternative, at least for inline markup, is facets. I took a while to come around to it, but it's a really nice model in that it finds a middle ground between forward and backward compatibility. You still have a human-readable plaintext block if you want to strip out all the annotations, but you can progressively render in whatever extra features you support.
Zicklag and I were talking to at the time about whether we could make our new schema compatible with Colibri, but Lou's schema makes blocks facets as well as marks. For this to reliably render to HTML correctly it requires a validation step that ensures blocks are non-overlapping, and in my opinion this design is a little fragile. There's nothing about facets that stops you from having overlapping byte ranges except app-level validation, which in my view makes them acceptable for inline markup but not block-level.
By contrast Leaflet's approach is to have a typed array of blocks, with each one supporting inline mark facets within that block, so I decided to follow their design. Once we have migrated onto permissioned spaces, we will return to the lexicon question with Lou and try to see if we can converge to a standard representation.
The migration was staged with a feature flag and kept backwards compatibility the whole time. This helped expose issues in parsing and rendering with the Discord bridge without breaking the app for everyone.
September
Loop engineering
I've had a goal for a while of using Roomy as an agent interface. I shared this in August after making a fair bit of progress on that, with a CLI package that's used by a separate bridge wrapping omp.sh:
This Roomy 'agent space' makes use of nine agents, each running on exe.dev VMs with their own ATProto account.3
But Roomy was appealing to me not just as an interface but also as a message board for agents (inspired a bit by those used by infamous incidents of agent swarm hacks at big labs). I was also inspired by Brittany Ellich's post about accidentally discovering loop engineering, so I decided to try implementing a task queue and loop system for Roomy. This was really chaotic to start with and needed a few rounds of taming to become useful. I included in the loop a direction to check not only the task list but also Grafana logs, metrics and traces in production, as well as a self-reflection process on the agent signalling process and fairly free licence to try to self-improve.
In a short time I had a process that would put timely, self-reviewed PRs on my fork and like Brittany experienced, make my manual review process the bottleneck. There's still much to improve and experiment with, but this system has been effective for me in making a fairly time-consuming and distractingly synchronous process into a fully async workflow, and as a result I could fix bugs and ship features significantly faster. The main exception is that I still prefer doing UI design work synchronously on my device since I don't typically use mockups and prefer to iterate in a tight loop.
Federated spaces
This feature is explained a bit more in this post by Erlend. TL;DR is that you can have channels from one space show up in a different space. This is a 'Roomy Space' channel accessible from the 'Muni Town' space:
This is a feature we've been pretty excited about. The technical details will be more interesting once it's implemented with ATProto permissioned spaces, since right now it's more or less just a fancy DB join (hint: it will involve 's work on the arbiter). Again the UX design was trickier as there are some significant user consent decisions we really want to be legible. We ended up with this request/approval flow and a layered permissions model that extends what we already have for channels.
Threads + Full text search
We are still yet to officially announce this feature, but we quietly shipped both thread search and full text search at the same time as Roomy Pro. We ended up combining them into one search UI:
I tried temporarily implementing full text search with SQLite's inbuilt BM25 virtual table, but it tanked performance. Since we don't require atomic consistency for search indexing, we went straight ahead with the main plan which was having it handled async by Qdrant.
Indexing the full message history in the background turned out to be somewhat error-prone, either skipping entire chunks of history or stalling, often due to issues like uncaught rejections. Addressing this was a good opportunity to add better error handling and recovery internally.
Roomy Pro
The last feature I'll talk about here is Roomy Pro which was my first time working with a billing platform, in our case polar.sh. I redesigned and implemented the marketing site, the in-app purchase flow using new membership status endpoints on the appserver, and usage limits on the Discord bridge.
The system is designed to be flexible, but also to have hard caps to prevent abuse, taking into account that our biggest operational cost for bridging is generally the initial backfill, and therefore we don't want users to use their membership to bridge and then unbridge multiple large spaces. To prevent this I designed a 'token' system that 'spends' the token above a certain usage threshold.
Reflections
As I shared recently, we ran out of money to pay me in mid-September, five and a half months after our decision to go all-in on ATProto at Atmosphere Conf:
I'm very grateful to have been entrusted with @erlend.sh's Discourse payout to keep me above water while working on this, and with that narrow $1.5k/month runway I've worked harder than I ever have. That money's run out now, so please know that if you're in a position to support us at all
That time went by really fast. In that period my intention has been to be ruthlessly pragmatic and driven by the goal of making our work financially sustainable, with launching Roomy Pro as the main actionable outcome. This is something that we would not have been able to do with our old architecture, because we had hit a hard performance and reliability ceiling and couldn't confidently provide a quality service at scale.
I'm very proud of the improvements that I've been able to make in performance and reliability since then as well as the features we've shipped. At the same time, it's also been very intense, underpaid work and leaving the launch of our paid service right until the very moment at which I no longer have job stability is a really difficult way to end that period. Sustainability doesn't happen overnight, and marketing and converting leads into a paying customer base is itself work that now must happen unpaid if it is to happen at all, on top of all the other features, fixes and improvements.
From an engineering perspective, I think the loop engineering system, especially combined with the preceding QA and observability work, was a key unlock and would have been worth pursuing sooner. Intentional work on context management (including documentation and code structure itself!), telemetry and testing has unexpectedly high leverage, and I still have much more to do to apply these learnings.
That said, all in all I learned an enormous amount over the last six months and think of it as an invaluable experience across a lot of different areas. I love what we've made with Roomy and am looking forward to what we'll be able to do with it over the next six months.
If any of my work and thinking here resonates with you or a problem you're dealing with, please feel free to get in touch with me on Bluesky or Signal.