<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>The Tiles Blog</title>
    <link>https://tiles.run/blog</link>
    <description>Open source privacy technology for personalized software experiences</description>
    <language>en-us</language>
    <lastBuildDate>Mon, 02 Nov 2026 13:07:31 GMT</lastBuildDate>
    <atom:link href="https://tiles.run/api/rss" rel="self" type="application/rss+xml"/>
    <managingEditor>hello@tiles.run (Tiles Privacy)</managingEditor>
    <webMaster>hello@tiles.run (Tiles Privacy)</webMaster>
    <item>
      <title><![CDATA[Tiles Privacy joins Mozilla Foundation’s Human-Scale AI Incubator as an anchor project]]></title>
      <link>https://tiles.run/blog/tiles-joins-mozilla-ai-incubator</link>
      <description><![CDATA[With support from Mozilla Foundation’s Human-Scale AI Incubator, we will spend the next 18 months building private, user-owned AI for the public interest.]]></description>
      <content:encoded><![CDATA[<p>Today, Tiles is joining Mozilla Foundation’s <a href="https://www.mozillafoundation.org/en/what-we-do/grantmaking/incubator/human-scale-ai/" target="_blank" rel="noopener noreferrer">Human-Scale AI Incubator</a> as one of three anchor projects. The Incubator brings together teams building purpose-built technology, sized to the problems they are meant to solve.</p><p>Privacy has historically been a niche market. I believe AI will change that. AI assistants and agent swarms are becoming the interface for more of our daily work, from knowledge work and coding to browsing. As that happens, attacks and data leaks will affect more people. Tiles is our bet that people will want tools that keep their data, identity, and control in their own hands.</p><p>For us, the hard part is building the underlying privacy technology and a sustainable business, not another chat interface. ChatGPT has already set a strong standard for how AI assistants look and feel. We can learn from those patterns while making Tiles distinctly ours: more playful, more human, and guided by the belief that work can feel like play. We want to build humane software, using polish to serve the user rather than drive addiction or endless engagement. Underneath, we are integrating local models and agent harnesses, building peer-to-peer networking, and creating zero-trust identity and authorization for people and agents. The goal is a decentralized, user-owned stack built to last, without ads or incentives that work against users.</p><p>That is why becoming an anchor project matters so much: it gives us time.</p><p>Mozilla’s support provides us with 18 months of runway for four full-time team members, one student on a short-term placement, and one legal consultant. Our core team brings together engineers with experience in cybersecurity, gaming, and peer-to-peer technologies, and a designer with a background in direct-to-consumer brands. Our legal consultant specializes in technology law and will work on the governance, risk, and compliance aspects of Tiles deployments with our pilot partners. This support gives us the freedom to build the local AI product we believe in without giving up equity or control.</p><figure class="my-8 lg:my-10"><img src="https://tiles.run/team.webp" alt="The five members of the Tiles Privacy team standing together outdoors." width="1600" height="1200" loading="lazy" decoding="async" class="h-auto w-full rounded-lg"><figcaption class="mt-2 text-sm text-black/48 dark:text-white/48">Members of the Tiles Privacy team.</figcaption></figure><p>During that time, we’ll work closely with Mozilla Foundation and the broader Human-Scale AI cohort through regular discussions, coaching, feedback, and community engagement.</p><p>We also plan to run practical pilots with nonprofits and educational institutions serving charitable and public-interest goals. Working in real settings will show us what people need and help us set better product priorities. The pilots will let us deploy carefully, learn from students, educators, program leaders, and communities, and make thoughtful decisions without the pressure of a sales cycle.</p><p>These pilots will also shape a governance and safeguarding framework for responsible institutional use. We will release it under an open-source license so nonprofits, schools, universities, and other organizations can adapt it for their own communities.</p><p>For me, long-term success is straightforward: Tiles becomes self-sustaining through user and enterprise revenue, giving us the independence to keep building.</p><p>Seven more spots are open now with up to $500k over two years in non-dilutive funding. Applications close November 20, 2026. If you’re building AI for a language, place, or need that mainstream systems overlook, we’d love to have you with us. Learn more and apply through the <a href="https://www.mozillafoundation.org/en/what-we-do/grantmaking/incubator/human-scale-ai/" target="_blank" rel="noopener noreferrer">Human-Scale AI Incubator</a>.</p><p>We’re grateful for Mozilla Foundation’s support and excited about what it will help us build next. We also want to thank our sponsors, advisors, and every member of the User &#x26; Agents community who played a vital role in helping Tiles reach this milestone.</p>]]></content:encoded>
      <pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate>
      <guid isPermaLink="true">https://tiles.run/blog/tiles-joins-mozilla-ai-incubator</guid>
      <dc:creator><![CDATA[Ankesh Bharti @feynon]]></dc:creator>
      <category><![CDATA[Announcements]]></category>
      <category><![CDATA[Mozilla Foundation]]></category>
      <category><![CDATA[Human-Scale AI]]></category>
      <category><![CDATA[anchor project]]></category>
      <category><![CDATA[purpose-built AI]]></category>
      <category><![CDATA[Tiles]]></category>
      <category><![CDATA[local-first]]></category>
      <category><![CDATA[open source]]></category>
    </item>
    <item>
      <title><![CDATA[Atmopsheric sessions]]></title>
      <link>https://tiles.run/blog/atmospheric-sessions</link>
      <description><![CDATA[Share Tiles chat sessions publicly or privately while retaining ownership with AT Protocol]]></description>
      <content:encoded><![CDATA[<p>In Tiles, the <a href="https://chat.tiles.run/YXQ6Ly9kaWQ6cGxjOm1iazZ3Z214aWF0b3R6eTViM3E1N25hdy9ydW4udGlsZXMuY2hhdC5zZXNzaW9uU25hcHNob3QvM21xamdheHR6c3kyZA==" target="_blank" rel="noopener noreferrer">chat sessions</a> we have with local models can be shared publicly. This is similar to sharing conversations as Shared Links in <a href="https://chatgpt.com/share/6a421d4b-797c-83ee-bee4-1da04e405976" target="_blank" rel="noopener noreferrer">ChatGPT</a> or via <a href="https://pi.dev/docs/latest/usage#exporting-and-sharing-sessions" target="_blank" rel="noopener noreferrer">Pi (using GitHub Gists)</a>. Sharing our research, ideas, and musings with others is valuable. What Tiles explores is how much ownership we can retain over those shared conversations.</p><figure class="atmospheric-session-figure"><img src="https://tiles.run/blog-atmospheric-sessions-public.jpg" alt="" /><figcaption>A publicly shared Tiles chat link</figcaption></figure><p>In Tiles, all chat sessions are completely local and stored encrypted on your device. So when we want to share those local conversations with the outside world, we should preserve the same ethos.</p><p>There are two ways to share sessions in Tiles. The first is to sync chats end to end encrypted, peer to peer, across your own devices, as described <a href="https://tiles.run/book/manual#sync" target="_blank" rel="noopener noreferrer">here</a>. That approach provides the strongest privacy and the most granular control, and deserves its own blog post.</p><p>But what if you simply want to share your research, ideas, or musings as a regular web link that anyone can open, even if they don't have Tiles installed, similar to ChatGPT's Shared Links, while still preserving data ownership? And what if other applications could build on those publicly shared conversations for entirely new use cases?</p><p>This is where <a href="https://atproto.com/" target="_blank" rel="noopener noreferrer">AT Protocol</a> comes into the picture.</p><h2 id="reaching-for-the-atmosphere">Reaching for the Atmosphere</h2><p>AT Protocol, also abbreviated as ATproto, is a decentralized social networking protocol that lets users publish and distribute self-authenticated data. That one-line description doesn't do it justice, so we highly recommend Dan Abramov's excellent explanation of it <a href="https://overreacted.io/open-social/" target="_blank" rel="noopener noreferrer">here</a>.</p><p>For our use case of sharing chat sessions, ATproto provides two important guarantees: a permanent identity and the ability to move your data anywhere without breaking existing shared links. Even if Tiles were to stop existing, your shared sessions would remain accessible, and anyone could build another application to display them. The data, its format, and the protocol are all open, much like Markdown.</p><p>Tiles doesn't own your shared sessions. They live in your PDS (Personal Data Server in the ATproto ecosystem), and you're free to move to another PDS whenever you want. When you share a session from Tiles using ATproto, the data is published to your PDS, not stored on Tiles' infrastructure. You can host your own PDS or choose one operated by someone you trust.</p><p>This separation of applications from identity and data is one of ATproto's core ideas. If you stop using one microblogging app and switch to another that also speaks ATproto, you don't lose your identity, your followers, or your posts. Your identity, social graph, and data move with you across applications, forming what the ATproto community calls the <a href="https://atproto.com/" target="_blank" rel="noopener noreferrer">Atmosphere</a>.</p><h2 id="sharing-sessions-with-tiles">Sharing sessions with Tiles</h2><p>To share a session from Tiles, you first need an ATproto account. The easiest way to get started is by creating a Bluesky account. Every ATproto account is associated with a PDS (Personal Data Server), which is where your data is hosted.</p><p>For example, you can create a Bluesky account and use <a href="https://bsky.app/" target="_blank" rel="noopener noreferrer">Bluesky's PDS</a>. There are, of course, other PDS providers as well, such as Northsky and Eurosky. The important point is that you can move your account to a different PDS later if you choose. For example, you can migrate from Bluesky's PDS to Northsky's without changing your ATproto handle or your underlying identity, both of which remain the same.</p><p>Once you have an ATproto account, you can sign in to it from Tiles using your ATproto handle, as described <a href="https://tiles.run/book/manual#atproto-tiles-at" target="_blank" rel="noopener noreferrer">here</a>. After that, you can open the Tiles CLI (with a web UI coming soon) and share either the current conversation or any existing session using the <code>/share</code> command.</p><p>Tiles publishes the session to your ATproto PDS and returns a public link that anyone can access. For more details, see the <a href="https://tiles.run/book/manual#sharing-commands" target="_blank" rel="noopener noreferrer">documentation</a>.</p><video class="atmospheric-session-video" controls playsinline preload="metadata" aria-label="Sharing a Tiles chat session through AT Protocol">
  <source src="https://tiles.run/atmospheric-sessions-share-demo.m4v" type="video/x-m4v" />
  Your browser does not support embedded video. <a href="https://tiles.run/atmospheric-sessions-share-demo.m4v">Download the video</a>.
</video><h2 id="what-about-private-sessions">What about private sessions?</h2><p>Sometimes we want to share a session only with specific people rather than making it public to everyone. One limitation of ATproto today is that records stored in a PDS are public by design, enabling data portability, transparency, and interoperability. There is ongoing work to support <a href="https://dholms.leaflet.pub/3meluqcwky22a" target="_blank" rel="noopener noreferrer">permissioned data</a>, but that is not available yet.</p><p>To preserve the privacy of sensitive Tiles sessions, Tiles supports private sharing by encrypting the session locally before it is uploaded to the PDS. The encrypted data is stored in your PDS, while the encryption key is placed in the URL fragment (<code>#...</code>). Since URL fragments are never sent to the server, decryption happens entirely in the recipient's browser. This means your PDS stores only encrypted data, and only someone with the complete link can decrypt and view the session.</p><figure class="atmospheric-session-figure"><img src="https://tiles.run/blog-atmospheric-sessions-private.jpg" alt="" /><figcaption>Encrypted sessions in Atproto PDS</figcaption></figure><p>Here's an example of a <a href="https://chat.tiles.run/YXQ6Ly9kaWQ6cGxjOm1iazZ3Z214aWF0b3R6eTViM3E1N25hdy9ydW4udGlsZXMuY2hhdC5zZXNzaW9uU25hcHNob3QvM21xbHpta3M0ZW8yaA==#TmqE5ZwyWaCl/6wCI5R0AsCWwlg2GZ5D.vyLaXFpy6dnB6G9jbHr8aOIxg5hBNeA+KB0v0DQrUkE=" target="_blank" rel="noopener noreferrer">private shared session</a>. If you inspect the URL, you'll notice that the encoded encryption key (<code>TmqE5ZwyWaCl/6wCI5R0AsCWwlg2GZ5D.vyLaXFpy6dnB6G9jbHr8aOIxg5hBNeA+KB0v0DQrUkE=</code>) is included as a URL fragment after the <code>#</code>. You can also verify that the corresponding data stored in the PDS is encrypted by inspecting it <a href="https://atproto.at/uri/at://did:plc:mbk6wgmxiatotzy5b3q57naw/run.tiles.chat.sessionSnapshot/3mqlzmks4eo2h" target="_blank" rel="noopener noreferrer">here</a>.</p><p>Because the encryption key never becomes part of the data stored in the PDS, the session remains encrypted while publicly hosted. Only someone with the complete URL can decrypt and view it, since decryption happens entirely in the browser. Under the hood, Tiles uses <a href="https://en.wikipedia.org/wiki/ChaCha20-Poly1305" target="_blank" rel="noopener noreferrer">ChaCha20-Poly1305</a> for authenticated encryption.</p><h2 id="interoperable-sessions">Interoperable sessions</h2><p>We mentioned earlier that sessions shared through Tiles can outlive Tiles itself. One reason is that the underlying data lives in a user-controlled PDS. But that alone isn't enough. If the data is stored in a proprietary format that requires reverse engineering to use outside of Tiles, then the only thing you've gained is the ability to export a data dump. <a href="https://newsletter.squishy.computer/p/credible-exit" target="_blank" rel="noopener noreferrer">Credible exit</a> requires more than that.</p><p>As mentioned earlier, ATproto already makes it possible to move from one social application to another without losing your identity, social graph, or data. The same principle should apply to AI conversations.</p><p>It's similar to how a Markdown file can be edited in Notepad and then opened in VS Code without any conversion. It would be even more powerful if we had that level of interoperability for everything, from social posts to LLM chat sessions.</p><p>In fact, this already exists for posts on ATproto. I can create a post in <a href="https://mu.social/" target="_blank" rel="noopener noreferrer">Mu</a>, then open and edit that same post in <a href="https://bsky.app/" target="_blank" rel="noopener noreferrer">Bluesky</a> while using the same ATproto account, the same identity, and the same underlying data storage. If I ever decide I no longer like Bluesky's interface, or simply prefer another client, I can switch to Mu without worrying about losing my followers or my content.</p><figure class="atmospheric-session-figure"><img src="https://tiles.run/blog-atmospheric-sessions-mu-social.jpg" alt="" /><figcaption>Creating a post on Mu</figcaption></figure><figure class="atmospheric-session-figure"><img src="https://tiles.run/blog-atmospheric-sessions-bluesky.jpg" alt="" /><figcaption>Viewing the same post on Bluesky</figcaption></figure><p>It would be exciting to bring that same interoperability to LLM chat sessions. Imagine publishing a local conversation from Tiles and opening it in a different chat viewer. Or imagine publishing a conversation created in ChatGPT, importing it into Tiles, continuing the conversation locally, publishing the updated session, and then opening it again in another compatible application. Just like a text file, the conversation would no longer be tied to a single app.</p><p>ATproto enables this kind of interoperability through <strong><a href="https://atproto.com/specs/lexicon" target="_blank" rel="noopener noreferrer">Lexicons</a></strong>. A Lexicon is essentially a JSON schema, with a few ATproto-specific extensions, that defines the structure of a particular type of data so it can be understood consistently across the network.</p><p>As a first step toward interoperable AI conversations, Tiles defines a <a href="https://lexicon.garden/lexicon/did:plc:mqmcsjuerbjhu65mpmvkcuw2/run.tiles.chat.sessionSnapshot" target="_blank" rel="noopener noreferrer">Lexicon</a> for LLM chat sessions called <code>run.tiles.chat.sessionSnapshot</code>.</p><div class="article-code-block-shell"><button type="button" class="article-code-wrap-toggle blog-print-screen-only" aria-pressed="false" aria-label="Wrap JSON lines">Wrap</button><pre data-wrap-control="true" data-code-wrap="false"><code class="language-json" data-syntax-highlighted="true">{
  <span class="syntax-json-key">"id"</span>: <span class="syntax-json-string">"run.tiles.chat.sessionSnapshot"</span>,
  <span class="syntax-json-key">"$type"</span>: <span class="syntax-json-string">"com.atproto.lexicon.schema"</span>,
  <span class="syntax-json-key">"lexicon"</span>: <span class="syntax-json-number">1</span>,
  <span class="syntax-json-key">"defs"</span>: {
    <span class="syntax-json-key">"main"</span>: {
      <span class="syntax-json-key">"key"</span>: <span class="syntax-json-string">"tid"</span>,
      <span class="syntax-json-key">"type"</span>: <span class="syntax-json-string">"record"</span>,
      <span class="syntax-json-key">"record"</span>: {
        <span class="syntax-json-key">"required"</span>: [<span class="syntax-json-string">"name"</span>, <span class="syntax-json-string">"sessionId"</span>, <span class="syntax-json-string">"createdAt"</span>, <span class="syntax-json-string">"turns"</span>],
        <span class="syntax-json-key">"properties"</span>: {
          <span class="syntax-json-key">"name"</span>: { <span class="syntax-json-key">"type"</span>: <span class="syntax-json-string">"string"</span> },
          <span class="syntax-json-key">"sessionId"</span>: { <span class="syntax-json-key">"type"</span>: <span class="syntax-json-string">"string"</span> },
          <span class="syntax-json-key">"createdAt"</span>: { <span class="syntax-json-key">"type"</span>: <span class="syntax-json-string">"string"</span>, <span class="syntax-json-key">"format"</span>: <span class="syntax-json-string">"datetime"</span> },
          <span class="syntax-json-key">"turns"</span>: {
            <span class="syntax-json-key">"type"</span>: <span class="syntax-json-string">"array"</span>,
            <span class="syntax-json-key">"items"</span>: {
              <span class="syntax-json-key">"type"</span>: <span class="syntax-json-string">"object"</span>,
              <span class="syntax-json-key">"properties"</span>: {
                <span class="syntax-json-key">"model"</span>: { <span class="syntax-json-key">"type"</span>: <span class="syntax-json-string">"string"</span> },
                <span class="syntax-json-key">"messages"</span>: {
                  <span class="syntax-json-key">"type"</span>: <span class="syntax-json-string">"array"</span>,
                  <span class="syntax-json-key">"items"</span>: { <span class="syntax-json-key">"ref"</span>: <span class="syntax-json-string">"#message"</span> }
                }
              }
            }
          }
        }
      }
    },
    <span class="syntax-json-key">"message"</span>: {
      <span class="syntax-json-key">"type"</span>: <span class="syntax-json-string">"object"</span>,
      <span class="syntax-json-key">"required"</span>: [<span class="syntax-json-string">"role"</span>, <span class="syntax-json-string">"content"</span>]
    },
    <span class="syntax-json-key">"contentItem"</span>: {
      <span class="syntax-json-key">"type"</span>: <span class="syntax-json-string">"object"</span>,
      <span class="syntax-json-key">"required"</span>: [<span class="syntax-json-string">"type"</span>]
    }
  }
}</code></pre></div><p>All sessions published through Tiles use this <a href="https://lexicon.garden/lexicon/did:plc:mqmcsjuerbjhu65mpmvkcuw2/run.tiles.chat.sessionSnapshot" target="_blank" rel="noopener noreferrer">Lexicon</a>. In that sense, the <a href="https://chat.tiles.run/" target="_blank" rel="noopener noreferrer">Tiles session viewer</a> acts as a free viewer for any session published in <a href="https://atproto.at/uri/at://did:plc:mbk6wgmxiatotzy5b3q57naw/run.tiles.chat.sessionSnapshot/3mqlxyvfm5u2d" target="_blank" rel="noopener noreferrer">this format</a>. You don't even need Tiles to create the data. Any application can publish a session directly to a user's PDS using the ATproto APIs, and the Tiles viewer can render it.</p><p>This opens up some interesting possibilities. Under the hood, Tiles uses <a href="https://pi.dev/" target="_blank" rel="noopener noreferrer">Pi</a> as its agent harness, and Pi is also used by several other assistants, including <a href="https://lucumr.pocoo.org/2026/1/31/pi/" target="_blank" rel="noopener noreferrer">OpenClaw</a>. Imagine if there were a standard Lexicon for Pi sessions. The level of interoperability would be remarkable. Conversations could move seamlessly between applications built on the same underlying session format.</p><p>For example, if you tagged a friend in a Tiles chat session, that mention wouldn't be lost when the conversation was opened in another ATproto-compatible AI assistant application. The conversation, its metadata, and its social context could all travel together.</p><p>We'll continue experimenting with ATproto as we build more features in Tiles. ATproto aligns closely with the principles we care about: collaboration, credible exit, and user agency. We believe conversations should be portable, interoperable, and ultimately owned by the people who create them.</p><p>That's the future we're building toward, so you can truly own your sessions.</p>]]></content:encoded>
      <pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate>
      <guid isPermaLink="true">https://tiles.run/blog/atmospheric-sessions</guid>
      <dc:creator><![CDATA[Anandu Pavanan @madcla.ws]]></dc:creator>
      <category><![CDATA[Tiles]]></category>
      <category><![CDATA[ATproto]]></category>
      <category><![CDATA[local-first]]></category>
      <category><![CDATA[interoperability]]></category>
    </item>
    <item>
      <title><![CDATA[Own your AI with local models and open protocols]]></title>
      <link>https://tiles.run/blog/own-your-ai</link>
      <description><![CDATA[A Local-First Conf talk about local models, open protocols, and user-owned AI]]></description>
      <content:encoded><![CDATA[<!-- The page at /blog/own-your-ai is built from src/lib/own-your-ai-talk.ts (slides and transcript). Keep this copy in step with it: it is what RSS, llms.txt and ATproto get. --><p>This post is adapted from a talk I gave at <a href="https://www.localfirstconf.com/" target="_blank" rel="noopener noreferrer">Local-First Conf</a> in Berlin in July 2026. It was written for the <a href="https://www.inkandswitch.com/local-first/" target="_blank" rel="noopener noreferrer">local-first</a> community, but it is relevant to anyone involved in building software.</p><p>As AI becomes central to how we work and communicate, I believe we are seeing a new form of technological feudalism, where a handful of platforms control the models, identities, data, and distribution channels people depend on. Local-first AI offers an alternative, but many tools still lack the identity, sync, and collaboration features people expect.</p><h2 id="local-first-conference-video">Local-First Conference Video</h2><p><a href="https://youtu.be/WY_M7TMQ8do" target="_blank" rel="noopener noreferrer">Watch the talk on YouTube</a></p><h2 id="slides-and-transcript">Slides and Transcript</h2><h3 id="slide-1">Slide 1</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 1.png" alt="Slide 1" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>Thank you, Adam, for the introduction, and thank you to the Local-First Conference team for organizing this event.</p><p>Hi everyone, I’m Ankesh. Usually I go by feynon on the internet.</p><p>I was here in the audience last year, and it genuinely changed the direction of my work, so it’s an honor to be back as a speaker.</p><h3 id="slide-2">Slide 2</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 2.png" alt="Slide 2" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>Today, I want to talk about reclaiming control of our digital lives in the age of AI.</p><p>I think that starts by running AI locally, on our own devices.</p><p>And it continues by building on open protocols instead of closed platforms.</p><p>Together, those two ideas let us own our data, our identity, and how our AI works.</p><h3 id="slide-3">Slide 3</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 3.png" alt="Slide 3" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>I love ChatGPT, and I’ve been using its products since they launched.</p><p>What keeps me coming back isn't just the models. It's the consistency of the user experience and the continuity that memory provides across its product surfaces.</p><p>It feels less like using a chatbot and more like using an app that knows me.</p><p>It started feeling more like a relationship.</p><p>And that made me ask a different question: what kind of relationship do I actually have with these systems?</p><h3 id="slide-4">Slide 4</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 4.png" alt="Slide 4" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>That's when I realized the relationship had become one-sided.</p><p>The more ChatGPT learned about me, the more power it held over my digital life.</p><p>If I stop paying the rent, part of my digital memory disappears with it.</p><p>And because I don’t fully trust who can access what I share, I can’t always express myself freely.</p><p>I love using AI.</p><p>I just don't love depending on something I don't own or control.</p><h3 id="slide-5">Slide 5</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 5.png" alt="Slide 5" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>I needed healthier boundaries.</p><p>I want to keep the same convenience without depending on blind trust.</p><p>I want my identity and memory to stay with me across every app I use, with trust that I can verify computationally.</p><p>I still want the relationship. I just want it to be on my terms.</p><h3 id="slide-6">Slide 6</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 6.png" alt="Slide 6" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>That naturally led me to one question.</p><p>Can we actually build AI like this today?</p><p>I think we finally can, with the technology we have available today.</p><p>Three things have changed.</p><h3 id="slide-7">Slide 7</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 7.png" alt="Slide 7" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>First, the frontier AI is no longer locked behind proprietary APIs.</p><p>For many practical workloads, frontier-level capabilities are already available in open-weight models such as GLM 5.2, as shown in this chart by Artificial Analysis.</p><h3 id="slide-8">Slide 8</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 8.png" alt="Slide 8" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>The second change is the amount of compute we now have in consumer devices.</p><p>Technologies like UltraFusion combine two Apple Silicon Max chips into a single Ultra chip, enabling medium-class local models to run on device.</p><h3 id="slide-9">Slide 9</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 9.png" alt="Slide 9" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>It’s not just about running frontier models on expensive on-device setups.</p><p>As frontier open-weight models improve, techniques like distillation rapidly transfer those gains to smaller local models.</p><p>Small models are delivering significantly more intelligence per watt.</p><h3 id="slide-10">Slide 10</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 10.png" alt="Slide 10" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>We’re also finding smarter ways to run larger models at the edge.</p><p>Apple’s upcoming Siri in iOS 27 is powered by Foundation Model 3, a distilled version of Gemini, and runs a 20B Mixture-of-Experts model directly on the iPhone.</p><p>It loads expert weights from fast flash storage instead of keeping the entire model in memory, and by loading them once per prompt rather than once per token, it largely compensates for the higher latency of flash storage.</p><h3 id="slide-11">Slide 11</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 11.png" alt="Slide 11" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>The third change is the growing adoption of open protocols like ATproto and ActivityPub among others.</p><p>Bluesky, built on ATproto, has grown to over 45 million users, with a thriving developer ecosystem building user-owned applications.</p><h3 id="slide-12">Slide 12</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 12.png" alt="Slide 12" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>We now have the models.</p><p>We now have the compute.</p><p>And we now have the protocols.</p><p>The technology is finally here.</p><p>But products haven’t caught up yet.</p><h3 id="slide-13">Slide 13</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 13.png" alt="Slide 13" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>Today’s local AI tools are technically excellent.</p><p>They’re built around private, personal use, and local AI is naturally great for that.</p><p>But privacy doesn’t mean I don’t want to collaborate.</p><p>I still want to share, work together, and move between my own devices, just on my own terms.</p><p>Most local AI products simply don’t support that.</p><h3 id="slide-14">Slide 14</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 14.png" alt="Slide 14" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>And when they do, they still depend on platforms.</p><p>Collaboration features usually require a centralized login, like “Sign in with Google.”</p><p>So instead of owning my identity, I’m still relying on someone else’s platform.</p><p>I just traded one platform dependency for another, even with supposedly private local AI tools.</p><h3 id="slide-15">Slide 15</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 15.png" alt="Slide 15" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>So I started thinking about what my dream local AI tool would look like.</p><p>Everything on this slide comes down to one idea.</p><p>I wanted all the polish of modern AI products, but working for me instead of the platform.</p><p>And that means polished on-device models, peer-to-peer sync, user-owned identity, shared links, and developer tools that make local AI feel just as seamless as the cloud.</p><h3 id="slide-16">Slide 16</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 16.png" alt="Slide 16" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>And it's not just my dream, it's a shared one.</p><p>This post, shared earlier today by Eileen, describes a similar vision of an open, local setup.</p><h3 id="slide-17">Slide 17</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 17.png" alt="Slide 17" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>That led me to build Tiles, a local-first private AI assistant.</p><p>Today it’s in alpha with a CLI-only experience.</p><p>Tiles is built to make ownership the default, without compromising on convenience.</p><h3 id="slide-18">Slide 18</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 18.png" alt="Slide 18" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>The architecture follows one simple principle.</p><p>Keep ownership on the device. Use the network only where it adds value.</p><p>The app, models, and your data live on your local devices.</p><p>The cloud is only used for collaboration. The ATproto PDS powers social features, while end-to-end encrypted peer-to-peer sync is done with Iroh relays.</p><h3 id="slide-19">Slide 19</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 19.png" alt="Slide 19" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>The technical architecture is split across three layers: Rust handles the core systems, Python powers inference, and TypeScript runs the agent runtime, which embeds Pi as a Bun binary. If you’d like to explore any particular module, I’d be happy to discuss it in more detail after the talk.</p><p>Next, here’s what that looks like across Tiles’ various features.</p><h3 id="slide-20">Slide 20</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 20.png" alt="Slide 20" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>Tiles includes gpt-oss-20b by default. It uses the Modelfile format, a plain-text blueprint for building and customizing local AI models.</p><h3 id="slide-21">Slide 21</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 21.png" alt="Slide 21" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>Plugins let users extend Tiles with reusable workflows by installing SKILL.md files.</p><h3 id="slide-22">Slide 22</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 22.png" alt="Slide 22" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>Tiles securely connects to local models running on remote machines over a peer-to-peer network, giving you access to your ambient AI compute wherever you are.</p><h3 id="slide-23">Slide 23</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 23.png" alt="Slide 23" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>That same peer-to-peer foundation enables encrypted chat synchronization across linked devices, using locally generated DIDs and UCANs for zero-trust identity and authorization.</p><h3 id="slide-24">Slide 24</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 24.png" alt="Slide 24" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>Finally, chat sessions can be shared through public or private links and published as ATproto Lexicon records.</p><h3 id="slide-25">Slide 25</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 25.png" alt="Slide 25" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>Now, with that overview, let’s move on to a demo I recorded yesterday after being delighted by Tristan’s demo of Caldir CLI.</p><h3 id="slide-26">Slide 26</h3><div class="my-6 overflow-hidden rounded-lg bg-black/5 dark:bg-white/5"><video class="block h-auto w-full" controls playsinline preload="metadata" aria-label="Slide 26 demo video"><source src="https://tiles.run/lofi-talk/demo.mp4" type="video/mp4"></video></div><p>This recorded demo shows Caldir running as a Tiles plugin with remote inference.</p><p>I’ll ask it to find events, then create an event. Finally, I’ll share the chat and show the saved ATProto PDS record as a Tiles lexicon.</p><p>What impressed me is that this speed was possible locally because of caldir’s simple, plaintext-first design, combined with the vertical integration we’ve built into Tiles across the inference stack.</p><h3 id="slide-27">Slide 27</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 27.png" alt="Slide 27" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>The three of us have been working full-time on maintaining Tiles for the past six months.</p><h3 id="slide-28">Slide 28</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 28.png" alt="Slide 28" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>I’m grateful for the guidance of Boris Mann, Dietrich Ayala, and Gordon Brander on this project.</p><h3 id="slide-29">Slide 29</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 29.png" alt="Slide 29" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>Tiles is really a product of the local-first community, and many of the technologies we use in Tiles came directly from the work of people in this room.</p><p>User &#x26; Agents has been an incredible community partner where many of these ideas took shape.</p><p>Darkshapes, our design partner, designed the Tiles logo and helped turn our ideas into the visual identity of Tiles.</p><h3 id="slide-30">Slide 30</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 30.png" alt="Slide 30" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>Special thanks to Dietrich and Luke, my co-founders at User &#x26; Agents.</p><p>Thank you to Boris, Xi Zhang, Hugo, and everyone who sponsored Tiles, shared advice, or helped along the way. I genuinely wouldn’t be standing here today without you.</p><p>If you’re interested in sponsoring Tiles, providing funding, or exploring collaboration opportunities, please come talk to me afterwards.</p><h3 id="slide-31">Slide 31</h3><div class="my-6"><img src="https://tiles.run/lofi-talk/slide 31.png" alt="Slide 31" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>To recap: I love ChatGPT, but I don’t want to compromise on convenience or control. Local AI and open protocols let us keep the convenience while making our data, identity, and memory portable—and truly ours.</p><p>I’d love for you to try Tiles on Mac or Linux and tell me what you think.</p><p>If you’d like to contribute or chat about local-first AI, please come find me afterwards.</p><p>Thank you.</p>]]></content:encoded>
      <pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate>
      <guid isPermaLink="true">https://tiles.run/blog/own-your-ai</guid>
      <dc:creator><![CDATA[Ankesh Bharti @feynon]]></dc:creator>
      <category><![CDATA[Tiles]]></category>
      <category><![CDATA[AI]]></category>
      <category><![CDATA[local-first]]></category>
      <category><![CDATA[ATproto]]></category>
    </item>
    <item>
      <title><![CDATA[Controlling the Ctrl-C]]></title>
      <link>https://tiles.run/blog/controlling-ctrl-c</link>
      <description><![CDATA[Observations while trying to properly exit from Tiles CLI]]></description>
      <content:encoded><![CDATA[<h2 id="the-repl-ui-issue">The REPL UI issue</h2><p>As a local AI assistant, Tiles embeds <a href="https://github.com/earendil-works/pi" target="_blank" rel="noopener noreferrer">Pi agent</a> for agent harness. Since the REPL is written in Rust and Pi is in TypeScript, we embed the Pi Bun binary and use it via Pi's RPC mode, spawn a headless Pi binary as a child process, and communicate with it via <a href="https://en.wikipedia.org/wiki/Standard_streams" target="_blank" rel="noopener noreferrer">standard streams (stdin/stdout/stderr)</a> in JSON format. The user input to the model and the streamed output from the model come and go through Pi. It sits between the REPL and the inference system.</p><div class="my-6 overflow-hidden rounded-lg bg-black/5 dark:bg-white/5"><img src="https://tiles.run/repl_flow.png" alt="Tiles REPL request and response flow" width="1719" height="915" loading="lazy" decoding="async" class="block h-auto w-full dark:hidden"><img src="https://tiles.run/repl_flow_dark.png" alt="Tiles REPL request and response flow" width="1744" height="902" loading="lazy" decoding="async" class="hidden h-auto w-full dark:block"></div><p>As with any LLM inference interface, Tiles REPL must stop the output streaming from the model as soon as the user presses Ctrl-C and the REPL should return to prompt state, ideally like this.</p><div class="my-6 overflow-hidden rounded-lg bg-black/5 dark:bg-white/5"><video class="block h-auto w-full" loop muted playsinline preload="none" data-play-on-approach poster="https://tiles.run/fixed-repl-poster.webp" aria-label="Tiles REPL stopping model output on Ctrl-C and returning to the prompt"><source src="https://tiles.run/fixed-repl.mp4" type="video/mp4"></video></div><p>But in the versions before v0.4.11, although the streaming ends and REPL returns to prompt state, on the next user input we were greeted with a broken pipe error like <code>Err value: Os { code: 32, kind: BrokenPipe, message: "Broken pipe" }</code>. Because of this, users had to restart the REPL to continue. More details are in this <a href="https://github.com/tilesprivacy/tiles/issues/146" target="_blank" rel="noopener noreferrer">issue</a>.</p><div class="my-6 overflow-hidden rounded-lg bg-black/5 dark:bg-white/5"><video class="block h-auto w-full" loop muted playsinline preload="none" data-play-on-approach poster="https://tiles.run/broke-pipe-err-poster.webp" aria-label="Tiles REPL broken pipe error after Ctrl-C before v0.4.11"><source src="https://tiles.run/broke-pipe-err.mp4" type="video/mp4"></video></div><h2 id="the-broken-pipes">The broken pipes</h2><p>As mentioned, we spawn the Pi process as a child process and communicate through its stdin and stdout. For this reason the streams are connected via pipes rather than the child inheriting the parent's streams (which is the default behavior). If the child inherited the parent's streams, communication would be messy and processes would have to filter what they need from the shared stream, which would introduce race conditions.</p><p>When piped, the parent can write to the child's stdin; the child writes to its own stdout, which the parent can then read, giving a clear separation of concerns. This is how we explicitly set the streams to be piped in Rust.</p><pre><code class="language-rust"><span class="rust-keyword">let</span> pi_process =
    <span class="rust-type">Command</span>::<span class="rust-fn">new</span>(pi_exec_path)
        .<span class="rust-fn">arg</span>(<span class="rust-string">"--mode"</span>)
        .<span class="rust-fn">arg</span>(<span class="rust-string">"rpc"</span>)
        <span class="rust-comment">// default is Stdio::inherit</span>
        .<span class="rust-fn">stdin</span>(<span class="rust-type">Stdio</span>::<span class="rust-fn">piped</span>())
        .<span class="rust-fn">stdout</span>(<span class="rust-type">Stdio</span>::<span class="rust-fn">piped</span>())
        .<span class="rust-fn">spawn</span>()
        .<span class="rust-fn">expect</span>(<span class="rust-string">"failed to run Pi"</span>);</code></pre><p>So when we press Ctrl-C, the SIGINT (signal interrupt) event is propagated to the child processes, and if they don't handle it then the default behavior is to exit. To monitor this, we can use the following commands on Unix systems.</p><pre><code class="language-rust">ps -o pid,ppid,pgid,sid,command | grep tiles

88068 65985 88608 target/debug/tiles</code></pre><p>Here the processId is 7011, the parent's processId is 65985 (system root is parent), and the process's groupId is 7011. To inspect the children, we can use:</p><pre><code class="language-rust"><span class="rust-comment">// where 88068 is pid for tiles</span>

pstree -p 88068

\-+= 88068 tiles target/debug/tiles
  |--= 88098 tiles target/debug/tiles daemon
  \--= 88099 tiles /<span class="rust-type">Users</span>/tiles/tiles/.tiles_dev/tiles/pi/pi --mode rpc</code></pre><p>We can see the Pi process is running as a child of Tiles with PID 88099.</p><p>We can use lsof to further monitor their relationship as follows:</p><pre><code class="language-rust">➜ lsof -p 88099 <span class="rust-comment">// Pi's PID (child)</span>
<span class="rust-type">COMMAND</span>   <span class="rust-type">PID</span>  <span class="rust-type">USER</span>   <span class="rust-type">FD</span>     <span class="rust-type">TYPE</span>             <span class="rust-type">DEVICE</span>   <span class="rust-type">SIZE</span>/<span class="rust-type">OFF</span>   <span class="rust-type">NODE</span> <span class="rust-type">NAME</span>
pi      88099 tiles    0     <span class="rust-type">PIPE</span> 0xd5c96c81e3aec5c2      16384   -&gt;0xa95f7f46dfb5efe6
pi      88099 tiles    1     <span class="rust-type">PIPE</span> 0x745d3d0e7a7709aa      16384   -&gt;0x27aceab27161a82

➜ lsof -p 88429 <span class="rust-comment">// Tile's PID (parent)</span>
<span class="rust-type">COMMAND</span>   <span class="rust-type">PID</span>  <span class="rust-type">USER</span>   <span class="rust-type">FD</span>     <span class="rust-type">TYPE</span>             <span class="rust-type">DEVICE</span>   <span class="rust-type">SIZE</span>/<span class="rust-type">OFF</span>   <span class="rust-type">NODE</span> <span class="rust-type">NAME</span>
tiles   88068 tiles   16     <span class="rust-type">PIPE</span> 0xa95f7f46dfb5efe6      16384   -&gt;0xd5c96c81e3aec5c2
tiles   88068 tiles   17     <span class="rust-type">PIPE</span>  0x27aceab27161a82      16384   -&gt;0x745d3d0e7a7709aa</code></pre><p>Here we can see Pi's stdin (FD=0) is connected to Tiles stdout (FD=16) and vice versa.</p><p>So when we press Ctrl-C to stop a streaming response, the signal propagates to the Pi process and Pi exits. When we try a new user prompt next time in the REPL, unbeknownst to the REPL that such a process does not exist, it still tries to write to Pi's non-existent stdin, which gives us a broken pipe, aka a broken connection.</p><h2 id="letting-them-go">Letting them go</h2><p>Since by default a child process is in the same process group (pgid) as the parent, the SIGINT event is propagated to all the processes in the group. One way is to remove the child from the same group as the parent. This means they still have a parent-child relationship, but the parent is no longer in direct control of the children. On Unix we can use <a href="https://www.man7.org/linux/man-pages/man2/setsid.2.html" target="_blank" rel="noopener noreferrer">setsid</a> for this to create a new session and set the current process as the leader of it.</p><p>But to do that in Rust is an <a href="https://doc.rust-lang.org/book/ch20-01-unsafe-rust.html" target="_blank" rel="noopener noreferrer">unsafe</a> operation (where we lose safety assurance from the compiler), as setsid is only available in the <a href="https://doc.rust-lang.org/beta/std/os/unix/process/trait.CommandExt.html" target="_blank" rel="noopener noreferrer">nightly version</a> as of now, so we need to call it via C <a href="https://en.wikipedia.org/wiki/Foreign_function_interface" target="_blank" rel="noopener noreferrer">FFI</a>. So setsid is achieved via other libraries such as <a href="https://docs.rs/libc/latest/libc/" target="_blank" rel="noopener noreferrer">libc</a>, <a href="https://docs.rs/nix/latest/nix/" target="_blank" rel="noopener noreferrer">nix</a>, etc., of course colored by the unsafe keyword.</p><pre><code class="language-rust"><span class="rust-keyword">let</span> pi_process = <span class="rust-keyword">unsafe</span> {
    <span class="rust-type">Command</span>::<span class="rust-fn">new</span>(pi_exec_path)
        .<span class="rust-fn">arg</span>(<span class="rust-string">"--mode"</span>)
        .<span class="rust-fn">arg</span>(<span class="rust-string">"rpc"</span>)
        .<span class="rust-fn">stdin</span>(<span class="rust-type">Stdio</span>::<span class="rust-fn">piped</span>())
        .<span class="rust-fn">stdout</span>(<span class="rust-type">Stdio</span>::<span class="rust-fn">piped</span>())
        <span class="rust-comment">// runs the below closure before executing the</span>
        <span class="rust-comment">// command function, here we run setsid for the</span>
        <span class="rust-comment">// spawned process.</span>
        .<span class="rust-fn">pre_exec</span>(|| {
            <span class="rust-keyword">if</span> libc::<span class="rust-fn">setsid</span>() == -1 {
                <span class="rust-keyword">return</span> <span class="rust-type">Err</span>(std::io::<span class="rust-type">Error</span>::<span class="rust-fn">last_os_error</span>());
            }
            <span class="rust-type">Ok</span>(())
        })
        .<span class="rust-fn">spawn</span>()
        .<span class="rust-fn">expect</span>(<span class="rust-string">"failed to run Pi"</span>)
};</code></pre><p>This resolves our broken pipe issue, as the SIGINT is never reaching the Pi process. But now we have another issue on hand: the model streaming is non-stoppable via Ctrl-C, and the REPL is completely unresponsive during the entire time we are streaming the output from Pi's stdout. It's no longer respecting SIGINT.</p><h2 id="controlling-the-control-c">Controlling the Control-C</h2><p>Thanks to a serendipitous moment while surfing the <a href="https://github.com/kkawakam/rustyline" target="_blank" rel="noopener noreferrer">rustyline</a> repo, which we use for building a nice UX for our REPL, we found that the version we were using (v17) had a <a href="https://github.com/kkawakam/rustyline/issues/929" target="_blank" rel="noopener noreferrer">bug</a> which masks the SIGINT event. So we upgraded to the latest version, and now we are receiving SIGINT while the model is streaming, but the SIGINT exits the program altogether instead of stopping the stream and returning to the user prompt.</p><p>This is in fact expected, as normally programs should handle SIGINT themselves if they want to do cleanups, graceful shutdowns, etc. So we use the <a href="https://docs.rs/ctrlc/latest/ctrlc/" target="_blank" rel="noopener noreferrer">ctrlc</a> library to handle SIGINT, which uses a dedicated thread for handling the event.</p><pre><code class="language-rust"><span class="rust-keyword">let</span> is_running = <span class="rust-type">Arc</span>::<span class="rust-fn">new</span>(<span class="rust-type">AtomicBool</span>::<span class="rust-fn">new</span>(<span class="rust-keyword">true</span>));
<span class="rust-keyword">let</span> is_running_ref = is_running.<span class="rust-fn">clone</span>();
ctrlc::<span class="rust-fn">set_handler</span>(<span class="rust-keyword">move</span> || {
    is_running_ref.<span class="rust-fn">store</span>(<span class="rust-keyword">false</span>, std::sync::atomic::<span class="rust-type">Ordering</span>::<span class="rust-type">SeqCst</span>);
})
....
....
<span class="rust-keyword">while</span> is_running.<span class="rust-fn">load</span>(std::sync::atomic::<span class="rust-type">Ordering</span>::<span class="rust-type">SeqCst</span>) {
 <span class="rust-comment">// loop breaks when SIGINT is fired and above handler toggles</span>
 <span class="rust-comment">// is_running to false</span>
}</code></pre><p>But again, for some reason we are back to square one where the REPL is non-responsive to Ctrl-C when it's streaming the output. As in the above signal handler is not getting called on SIGINT.</p><p>Turns out the way we read from Pi's stdout is a synchronous, blocking operation. So we tried converting all the functions related to this to async using the corresponding async functions provided by <a href="https://tokio.rs/" target="_blank" rel="noopener noreferrer">tokio</a> (an async runtime library for Rust). For example, the core operation here is using a buffered reader to read from Pi's stdout efficiently, so we replace the <a href="https://doc.rust-lang.org/stable/std/io/struct.BufReader.html" target="_blank" rel="noopener noreferrer">BufReader</a> from the std library with the async <a href="https://docs.rs/tokio/latest/tokio/io/struct.BufReader.html" target="_blank" rel="noopener noreferrer">BufReader</a> provided by the Tokio runtime.</p><blockquote><p>Tokio uses co-operative scheduling to switch between its tasks, so when we use an async function, it will yield frequently instead of blocking throughout the process.</p></blockquote><p>Once we refactored the codebase to be async, we started getting SIGINT events in the handler we set using the ctrlc library before, and the program no longer exits either. Now all we have to do is abort the Pi session by sending an abort event to Pi and do the cleanup from our side.</p><pre><code class="language-rust"><span class="rust-keyword">while</span> <span class="rust-keyword">let</span> <span class="rust-type">Some</span>(line) = reader.<span class="rust-fn">next_line</span>().<span class="rust-keyword">await</span>? {
   <span class="rust-keyword">if</span> !is_running.<span class="rust-fn">load</span>(std::sync::atomic::<span class="rust-type">Ordering</span>::<span class="rust-type">SeqCst</span>) {
      <span class="rust-macro">info!</span>(<span class="rust-string">"Ctrlc detected, aborting Pi ops"</span>);
      <span class="rust-keyword">let</span> end_payload = <span class="rust-macro">json!</span>({
          <span class="rust-string">"type"</span>: <span class="rust-string">"abort"</span>,
       });
       <span class="rust-comment">// sending abort event to Pi</span>
       <span class="rust-fn">send_to_pi</span>(pi_stdin, end_payload).<span class="rust-keyword">await</span>?;
       <span class="rust-comment">// toggling is_running back to true, once we handled</span>
       <span class="rust-comment">// the ctrl-c</span>
       is_running.<span class="rust-fn">store</span>(<span class="rust-keyword">true</span>, std::sync::atomic::<span class="rust-type">Ordering</span>::<span class="rust-type">SeqCst</span>);
       <span class="rust-keyword">continue</span>;
}</code></pre><p>For more details on the sync-async conversion, see the <a href="https://github.com/tilesprivacy/tiles/pull/152/changes#diff-684db0fd5a4dc082dd110af19d9451265fb5fbf03ce2dfe62127cf5ee8194be4" target="_blank" rel="noopener noreferrer">PR diff</a>.</p><h2 id="unexpected-entry-of-sigpipe">Unexpected entry of SIGPIPE</h2><p>The interesting thing now is that when we exit the main REPL program, the Pi process also exits, which shouldn't be the case as both are now in different process groups, right? Could this be related to the pipes getting closed on one end?</p><p>Although this is fine for us, as we don't want the Pi process to be a background daemon and go rogue, it's important to understand what's happening under the hood, as we also have a Tiles daemon process (which is a background headless Tiles HTTP server) that is still alive even after the main REPL program closes, as it is supposed to be (this was also spawned in a different process group).</p><pre><code class="language-rust"><span class="rust-comment">// where 88068 is pid for tiles</span>

pstree -p 88068

\-+= 88068 tiles target/debug/tiles
  |--= 88098 tiles target/debug/tiles daemon
  \--= 88099 tiles /<span class="rust-type">Users</span>/tiles/tiles/.tiles_dev/tiles/pi/pi --mode rpc</code></pre><p>PID=88098 is our daemon.</p><p>Why the dual behavior for the same action? For that we can live-debug the Pi program using lldb (LLVM debugger) to see what happens when the parent exit. We will attach the Pi process to lldb, add a breakpoint for SIGPIPE, then step through to see if Pi is handling SIGPIPE or not. The actions we did are commented with numbered index.</p><pre><code class="language-rust"><span class="rust-comment">// (1) Starting lldb for the Pi process</span>
➜ sudo lldb -p 88099

<span class="rust-type">No</span> entry <span class="rust-keyword">for</span> terminal <span class="rust-keyword">type</span> <span class="rust-string">"xterm-ghostty"</span>;
using dumb terminal settings.
<span class="rust-comment">// (2) Attaching Pi PID to the debugger</span>
(lldb) process attach --pid 88099

<span class="rust-type">Process</span> 88099 stopped
<span class="rust-type">Target</span> 0: (pi) stopped.
<span class="rust-type">Executable</span> binary set to <span class="rust-string">"/Users/tiles/tiles/.tiles_dev/tiles/pi/pi"</span>.
<span class="rust-type">Architecture</span> set to: arm64-apple-macosx-.
<span class="rust-type">No</span> entry <span class="rust-keyword">for</span> terminal <span class="rust-keyword">type</span> <span class="rust-string">"xterm-ghostty"</span>;
using dumb terminal settings.

<span class="rust-comment">// (3) Adding a breakpoint for SIGPIPE and notify us</span>
(lldb) <span class="rust-keyword">break</span> set -n write
<span class="rust-type">Breakpoint</span> 1: 19 locations.
(lldb) process handle <span class="rust-type">SIGPIPE</span> --pass <span class="rust-keyword">false</span> --stop <span class="rust-keyword">true</span> --notify <span class="rust-keyword">true</span>
<span class="rust-type">NAME</span>         <span class="rust-type">PASS</span>   <span class="rust-type">STOP</span>   <span class="rust-type">NOTIFY</span>
===========  =====  =====  ======
<span class="rust-type">SIGPIPE</span>      <span class="rust-keyword">false</span>  <span class="rust-keyword">true</span>   <span class="rust-keyword">true</span>

<span class="rust-comment">// (4) Resuming the debugger</span>
(lldb) process <span class="rust-keyword">continue</span>

<span class="rust-comment">// At this point we exit the Tiles repl and debugger pauses</span>
<span class="rust-comment">// Pi</span>
<span class="rust-type">Process</span> 88099 resuming
<span class="rust-type">Process</span> 88099 stopped

<span class="rust-comment">// (5) and continue the Pi program after resuming after the breakpoint</span>
(lldb) process <span class="rust-keyword">continue</span>
<span class="rust-comment">// We can see that Pi program exits as soon as it</span>
<span class="rust-comment">// receives SIGPIPE</span>
<span class="rust-type">Process</span> 88099 resuming
<span class="rust-type">Process</span> 88099 exited with status = 0 (0x00000000)</code></pre><p>As seen in the lldb logs, the program exits as soon as it receives SIGPIPE, so Pi doesn't have a handler for SIGPIPE, which causes it to exit.</p><h2 id="conclusion">Conclusion</h2><p>Debugging a seemingly trivial terminal UI issue led us into a rabbit hole of standard streams, pipes, Unix processes, and their dynamic behavior on system signals with respect to their parent, and finally to the problems caused by blocking I/O in a UI and how async Rust can fix it.</p>]]></content:encoded>
      <pubDate>Mon, 08 Jun 2026 00:00:00 GMT</pubDate>
      <guid isPermaLink="true">https://tiles.run/blog/controlling-ctrl-c</guid>
      <dc:creator><![CDATA[Anandu Pavanan @madcla.ws]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Tiles]]></category>
      <category><![CDATA[Rust]]></category>
      <category><![CDATA[REPL]]></category>
      <category><![CDATA[Pi]]></category>
      <category><![CDATA[SIGINT]]></category>
      <category><![CDATA[SIGPIPE]]></category>
      <category><![CDATA[Tokio]]></category>
      <category><![CDATA[async IO]]></category>
      <category><![CDATA[Unix signals]]></category>
    </item>
    <item>
      <title><![CDATA[Ship it up]]></title>
      <link>https://tiles.run/blog/ship-it-up</link>
      <description><![CDATA[How we package and ship Tiles]]></description>
      <content:encoded><![CDATA[<p>We ship Tiles as a <code>tar.gz</code> tarball and as a native macOS <code>.pkg</code>. Either format follows the same broad steps.</p><ul><li><p>Assemble the deliverable:</p><ul class="mt-2"><li>Package the Tiles binary and the Python <a href="https://tiles.run/blog/move-along-python">venvstack</a> artifacts into one installer payload.</li></ul></li><li><p>Run the installer:</p><ul class="mt-2"><li>Copy the Tiles binary and Python artifacts into the correct locations on disk.</li></ul></li><li><p>Run postinstall scripts.</p></li></ul><p>In early releases, the tarball was the only install path. It served us well, but it had clear limits.</p><ul><li><p>Because the release artifact is a gzip tarball, we cannot codesign, notarize, and staple the archive itself the way we can a full installer. We run those steps on the Tiles binary only, not on the entire <code>.gz</code> file.</p></li><li><p>Installation relies on a curl-based script. That is workable for developers and rougher for everyone else.</p></li><li><p>The flow leaves little room to tailor copy, branding, or steps in the installer UI.</p></li></ul><p>We wanted a path that fits Apple's signing and notarization story and feels native on macOS. Installers ship as <code>.dmg</code> or <code>.pkg</code> bundles; we chose <code>.pkg</code> because it can run scripts, uses a familiar installer UI, and supports customization with simple HTML. What follows is how we build that <code>.pkg</code> for Tiles.</p><h2 id="install-file-structure">Install file structure</h2><div class="my-6"><img src="https://tiles.run/blog-ship-it-up-pkgroot-structure.jpg" alt="" width="846" height="490" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>The layout matches the directory tree above. When we build the package, we point the tooling at this <code>pkgroot</code> directory as the install root. The installer copies that tree onto the destination volume and creates intermediate directories as needed.</p><p>For each release, we build a fresh Tiles binary into <code>pkgroot/usr/local/bin</code> and place the remaining updated artifacts under <code>pkgroot/usr/local/share/tiles/</code>.</p><p>The <a href="https://github.com/tilesprivacy/tiles/blob/main/pkg/build.sh" target="_blank" rel="noopener noreferrer">build script</a> in the repo has the full sequence.</p><h2 id="code-signing-the-tiles-binary">Code signing the Tiles binary</h2><p>Code signing gives us:</p><ul><li><p>A signed Tiles binary that cannot be altered without invalidating the signature.</p></li><li><p>A signature from a developer certificate that Apple trusts.</p></li></ul><p>You need an Apple Developer account and two certificate types: <strong>DEVELOPER ID APPLICATION</strong> for the binary and <strong>DEVELOPER ID INSTALLER</strong> for the <code>.pkg</code>. The finished installer wraps the signed binary and the other artifacts in one compressed package.</p><p>For creating and exporting those certificates, the CodeVamping <a href="https://www.codevamping.com/2023/11/macos-pkg-installer/" target="_blank" rel="noopener noreferrer">macOS pkg installer article</a> covers the details.</p><pre><code># Signing the Tiles binary
codesign --force \
  --sign "$DEVELOPER_ID_APPLICATION"\
  --options runtime \
  --timestamp \
  --strict \
  "${CLI_BIN_PATH}/tiles"</code></pre><p>The snippet signs the Tiles CLI. <code>$DEVELOPER_ID_APPLICATION</code> is an environment variable that holds the common name of the Developer ID Application certificate.</p><h2 id="scripts">Scripts</h2><p>Bash scripts can run before and after the payload is laid down. We keep <strong>preinstall</strong> and <strong>postinstall</strong> scripts in a directory and pass that path into <code>pkgbuild</code>.</p><p>The Tiles repo has <a href="https://github.com/tilesprivacy/tiles/tree/main/pkg/scripts" target="_blank" rel="noopener noreferrer">those scripts</a>; they handle cleanup and internal setup.</p><h2 id="building-the-tiles-package">Building the Tiles package</h2><p>With the signed Tiles binary and the rest of the install tree under <code>pkgroot</code>, we run:</p><pre><code>pkgbuild --root pkgroot --scripts \
 pkg/scripts --identifier com.tilesprivacy.tiles --version "$VERSION" \
 pkg/tiles-unsigned.pkg</code></pre><p><code>--root</code> is <code>pkgroot</code>; <code>--scripts</code> points at the directory from the previous section.</p><h2 id="customizing-the-installer">Customizing the installer</h2><div class="my-6"><img src="https://tiles.run/blog-ship-it-up-installer-custom-1.jpg" alt="" width="1242" height="882" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><p>Opening the unsigned package from the previous step shows Apple's default, minimal flow. We layer on welcome and conclusion screens, a logo, and other panels with an Apple <strong><a href="https://developer.apple.com/library/archive/documentation/DeveloperTools/Reference/DistributionDefinitionRef/Chapters/Introduction.html#//apple_ref/doc/uid/TP40005370-CH1-SW1" target="_blank" rel="noopener noreferrer">distribution</a></strong> definition in XML. That file references HTML for the welcome, conclusion, and other supported steps. Our <a href="https://github.com/tilesprivacy/tiles/blob/main/pkg/distribution_network.xml" target="_blank" rel="noopener noreferrer">distribution_network.xml</a> is the working example. Elements such as <code>&#x3C;welcome/></code> and <code>&#x3C;conclusion/></code> point at resources we keep alongside the definition and pass into the final <code>productbuild</code> invocation.</p><pre><code>productbuild \
  --distribution pkg/distribution_network.xml \
  --resources pkg/resources \
  --package-path pkg/  \
  pkg/tiles-dist-unsigned.pkg</code></pre><p><code>productbuild</code> reads the unsigned package and writes a distributable installer with those customizations baked in.</p><div class="my-6"><img src="https://tiles.run/blog-ship-it-up-installer-custom-2.jpg" alt="" width="1242" height="882" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><div class="my-6"><img src="https://tiles.run/blog-ship-it-up-installer-custom-3.jpg" alt="" width="1242" height="882" loading="lazy" decoding="async" class="h-auto w-full rounded-lg border border-black/10 dark:border-white/10"></div><h2 id="code-signing-the-complete-installer">Code signing the complete installer</h2><pre><code>productsign \
  --sign "$DEVELOPER_ID_INSTALLER" \
  pkg/tiles-dist-unsigned.pkg \
  pkg/tiles.pkg</code></pre><p>That signs the <code>.pkg</code> itself. The Tiles binary is already signed; signing a macOS binary and signing an installer package are different operations.</p><p>The main difference is <code>productsign</code> instead of <code>codesign</code>, and the certificate we use is <code>DEVELOPER ID INSTALLER</code> instead of <code class="whitespace-pre-wrap">DEVELOPER  ID APPLICATION</code>.</p><h2 id="notarizing-the-installer">Notarizing the installer</h2><p>Notarization lets Apple scan the payload for malware. We upload the installer; when processing finishes, the service returns acceptance or rejection.</p><p>Submission looks like this:</p><pre><code>xcrun notarytool submit pkg/tiles.pkg \
  --keychain-profile "tiles-notary-profile" \
  --wait</code></pre><p><code>tiles-notary-profile</code> is the keychain entry name so we do not pass Apple ID details on every run. Store the profile once with:</p><pre><code>xcrun notarytool store-credentials \
  "tiles-notary-profile" \
  --apple-id "john.doe@gmail.com" \
  --team-id "X********4" \
  --password "****-****-****-****"</code></pre><h2 id="stapling-the-installer">Stapling the installer</h2><p>Stapling embeds the notarization ticket in the installer file so Gatekeeper needs fewer round trips to Apple when a user opens the package.</p><pre><code>xcrun stapler staple pkg/tiles.pkg</code></pre><h2 id="whats-next">What's next</h2><p>We also ship a fully offline installer that bundles OpenAI's gpt-oss 20B model, so Tiles can be installed without an internet connection. We are working toward making Tiles itself portable, allowing users to carry their model and data and run it from a flash drive on any compatible system.</p><p>The installer requires Rosetta; on Apple Silicon Macs, it is not included by default and must be installed separately. Rosetta translates Intel binaries to run on Apple Silicon. There is a workaround for this, described in the <a href="https://github.com/tilesprivacy/tiles/issues/105" target="_blank" rel="noopener noreferrer">linked GitHub issue</a>.</p>]]></content:encoded>
      <pubDate>Sun, 05 Apr 2026 00:00:00 GMT</pubDate>
      <guid isPermaLink="true">https://tiles.run/blog/ship-it-up</guid>
      <dc:creator><![CDATA[Anandu Pavanan @madcla.ws]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Tiles]]></category>
      <category><![CDATA[packaging]]></category>
      <category><![CDATA[deployment]]></category>
      <category><![CDATA[software distribution]]></category>
      <category><![CDATA[venvstacks]]></category>
      <category><![CDATA[Python packaging]]></category>
    </item>
    <item>
      <title><![CDATA[Move Along, Python]]></title>
      <link>https://tiles.run/blog/move-along-python</link>
      <description><![CDATA[Deterministic, portable Python runtimes for Tiles using layered venvstacks]]></description>
      <content:encoded><![CDATA[<p>We have been working on Tiles. Tiles is a local-first private AI assistant that runs on-device models with encrypted P2P sync, keeps your data and identity yours, and supports sharing chats with ATproto. To ensure its Python model server starts predictably on any machine, the runtime and dependencies must be deterministic and portable. This post walks through how we achieve that with layered venvstacks.</p><h2 id="the-python-problem">The Python Problem</h2><p>Right now, we have a polyglot architecture where the control plane and CLI are written in Rust, while local model inference runs through a Python server as a daemon. Ideally, when we ship Tiles, we should also ship the required artifacts needed to run Python on the user’s system.</p><p>Since Python servers cannot be compiled into a single standalone binary, the user’s system must have a Python runtime available. More importantly, it must be a deterministic Python runtime so that the server runs exactly on the version developers expect.</p><p>In earlier releases of Tiles (before 0.4.0), we packed the server files into the final release tarball. During installation, we extracted them to the user’s system, downloaded <code>uv</code> (a Python package manager), installed Python 3.13 if it was not already present, and then ran the server as a daemon.</p><p>This approach had several issues:</p><ul><li>Downloading development-related tools such as <code>uv</code> onto the user’s system</li><li>Relying on <code>uv</code> at install time to manage dependencies and run the server</li><li>Increased chances of failures due to dependency or runtime non-determinism</li><li>Requiring internet access to download all of the above tools</li><li>Lack of a fully deterministic runtime across operating systems</li></ul><p>One of the long-term goals of Tiles is complete portability. The previous approach did not align with that vision.</p><h2 id="portable-runtimes">Portable Runtimes</h2><p>To address these issues, we decided to ship the runtime along with the release tarball. We are now using <a href="https://lmstudio.ai/blog/venvstacks" target="_blank" rel="noopener noreferrer">venvstacks</a> by <a href="https://lmstudio.ai" target="_blank" rel="noopener noreferrer">LM Studio</a> to achieve this.</p><p>Venvstacks allows us to build a layered Python environment with three layers:</p><ul><li><strong>Runtimes</strong><br>
Defines the exact Python runtime version we need.</li><li><strong>Frameworks</strong><br>
Specifies shared Python frameworks such as NumPy, MLX, and others.</li><li><strong>Applications</strong><br>
Defines the actual server application and its specific dependencies.</li></ul><p>Similar to Docker, each layer depends on the layer beneath it. A change in any layer requires rebuilding that layer and the ones above it.</p><p>All components within a layer share the layers beneath them. For example, every framework uses the same Python runtime defined in the <code>runtimes</code> layer. Likewise, if we have multiple servers in the <code>applications</code> layer and both depend on MLX, they will share the exact deterministic MLX dependency defined in <code>frameworks</code>, as well as the same Python runtime defined in <code>runtimes</code>.</p><h2 id="how-venvstacks-is-used-in-tiles">How venvstacks is used in Tiles</h2><p>We define everything inside a <code>venvstacks.toml</code> file. Here is the <a href="https://github.com/tilesprivacy/tiles/blob/main/server/stack/venvstacks.toml" target="_blank" rel="noopener noreferrer">venvstacks.toml</a> used in Tiles.</p><p>Because we pin dependency versions in the TOML file, we eliminate non-determinism.</p><p>Internally, venvstacks uses <code>uv</code> to manage dependencies. Once the TOML file is defined, we run:</p><pre><code>venvstacks lock venvstacks.toml
venvstacks build venvstacks.toml
venvstacks publish venvstacks.toml</code></pre><p>These commands resolve dependencies, create the necessary folders, lock files, and metadata for each layer, build the Python runtime and environments based on the lock files, and produce reproducible tarballs that can be unpacked on external systems and run directly.</p><p>We bundle the venvstack runtime artifacts into the final installer using this <a href="https://github.com/tilesprivacy/tiles/blob/main/scripts/bundler.sh" target="_blank" rel="noopener noreferrer">bundler script</a>. During installation, this <a href="https://github.com/tilesprivacy/tiles/blob/main/scripts/install.sh" target="_blank" rel="noopener noreferrer">installer script</a> extracts the venvstack tarballs into a deterministic directory.</p><p>Our Rust CLI can then predictably start the Python server using:</p><pre><code>stack_export_prod/app-server/bin/python -m server.main</code></pre><h2 id="whats-next">What’s Next</h2><p>We tested version 0.4.0 on clean macOS virtual machines to verify portability, and the approach worked well.</p><p>For now, we are focusing only on macOS. When we expand support to other operating systems, we will revisit this setup and adapt it as needed.</p><p>Packaging the runtime and dependencies increases the size of the final installer. We are exploring ways to reduce that footprint.</p><p>We also observed that changes in lock files can produce redundant application tarballs when running the <code>publish</code> command. More details are tracked in this <a href="https://github.com/tilesprivacy/tiles/issues/84" target="_blank" rel="noopener noreferrer">issue</a>.</p><p>Overall, we are satisfied with this approach for now.</p>]]></content:encoded>
      <pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate>
      <guid isPermaLink="true">https://tiles.run/blog/move-along-python</guid>
      <dc:creator><![CDATA[Anandu Pavanan @madcla.ws]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Python]]></category>
      <category><![CDATA[venvstacks]]></category>
      <category><![CDATA[portable runtimes]]></category>
      <category><![CDATA[Python packaging]]></category>
      <category><![CDATA[dependency management]]></category>
      <category><![CDATA[Tiles]]></category>
      <category><![CDATA[deterministic builds]]></category>
    </item>
  </channel>
</rss>