<?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://www.tiles.run/blog</link>
    <description>Open source privacy technology for personalized software experiences</description>
    <language>en-us</language>
    <lastBuildDate>Mon, 15 Jun 2026 14:42:49 GMT</lastBuildDate>
    <atom:link href="https://www.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[Controlling the Ctrl-C]]></title>
      <link>https://www.tiles.run/blog/controlling-ctrl-c</link>
      <description><![CDATA[Observations while trying to properly exit from Tiles CLI]]></description>
      <content:encoded><![CDATA[<h2>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>

<picture style="display: block; width: 100%; margin: 2rem 0;">
  <source srcset="/repl_flow_dark.png" media="(prefers-color-scheme: dark)" />
  <img src="https://www.tiles.run/repl_flow.png" alt="Tiles REPL request and response flow" style="width: 100%; height: auto;" />
</picture>

<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>

<video src="https://www.tiles.run/fixed-repl.mp4" poster="/fixed-repl-poster.webp" autoplay loop muted playsinline aria-label="Tiles REPL stopping model output on Ctrl-C and returning to the prompt" style="width: 100%; height: auto; margin: 2rem 0;"></video>

<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>

<video src="https://www.tiles.run/broke-pipe-err.mp4" poster="/broke-pipe-err-poster.webp" autoplay loop muted playsinline aria-label="Tiles REPL broken pipe error after Ctrl-C before v0.4.11" style="width: 100%; height: auto; margin: 2rem 0;"></video>

<h2>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">let pi_process =
    Command::new(pi_exec_path)
        .arg("--mode")
        .arg("rpc")
        // default is Stdio::inherit
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .expect("failed to run Pi");</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">// where 88068 is pid for tiles

pstree -p 88068

\-+= 88068 tiles target/debug/tiles
  |--= 88098 tiles target/debug/tiles daemon
  \--= 88099 tiles /Users/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 // Pi's PID (child)
COMMAND   PID  USER   FD     TYPE             DEVICE   SIZE/OFF   NODE NAME
pi      88099 tiles    0     PIPE 0xd5c96c81e3aec5c2      16384   ->0xa95f7f46dfb5efe6
pi      88099 tiles    1     PIPE 0x745d3d0e7a7709aa      16384   ->0x27aceab27161a82

➜ lsof -p 88429 // Tile's PID (parent)
COMMAND   PID  USER   FD     TYPE             DEVICE   SIZE/OFF   NODE NAME
tiles   88068 tiles   16     PIPE 0xa95f7f46dfb5efe6      16384   ->0xd5c96c81e3aec5c2
tiles   88068 tiles   17     PIPE  0x27aceab27161a82      16384   ->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>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">let pi_process = unsafe {
    Command::new(pi_exec_path)
        .arg("--mode")
        .arg("rpc")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        // runs the below closure before executing the
        // command function, here we run setsid for the
        // spawned process.
        .pre_exec(|| {
            if libc::setsid() == -1 {
                return Err(std::io::Error::last_os_error());
            }
            Ok(())
        })
        .spawn()
        .expect("failed to run Pi")
};</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>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">let is_running = Arc::new(AtomicBool::new(true));
let is_running_ref = is_running.clone();
ctrlc::set_handler(move || {
    is_running_ref.store(false, std::sync::atomic::Ordering::SeqCst);
})
....
....
while is_running.load(std::sync::atomic::Ordering::SeqCst) {
 // loop breaks when SIGINT is fired and above handler toggles
 // is_running to false
}</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.</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">while let Some(line) = reader.next_line().await? {
   if !is_running.load(std::sync::atomic::Ordering::SeqCst) {
      info!("Ctrlc detected, aborting Pi ops");
      let end_payload = json!({
          "type": "abort",
       });
       // sending abort event to Pi
       send_to_pi(pi_stdin, end_payload).await?;
       // toggling is_running back to true, once we handled
       // the ctrl-c
       is_running.store(true, std::sync::atomic::Ordering::SeqCst);
       continue;
}</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>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's supposed to be (this was also spawned in a different process group).</p>

<pre><code class="language-rust">// where 88068 is pid for tiles

pstree -p 88068

\-+= 88068 tiles target/debug/tiles
  |--= 88098 tiles target/debug/tiles daemon
  \--= 88099 tiles /Users/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 exits. 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 take are commented with numbered index.</p>

<pre><code class="language-rust">// (1) Starting lldb for the Pi process
➜ sudo lldb -p 88099

No entry for terminal type "xterm-ghostty";
using dumb terminal settings.
// (2) Attaching Pi PID to the debugger
(lldb) process attach --pid 88099

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

// (3) Adding a breakpoint for SIGPIPE and notify us
(lldb) break set -n write
Breakpoint 1: 19 locations.
(lldb) process handle SIGPIPE --pass false --stop true --notify true
NAME         PASS   STOP   NOTIFY
===========  =====  =====  ======
SIGPIPE      false  true   true

// (4) Resuming the debugger
(lldb) process continue

// At this point we exit the Tiles repl and debugger pauses
// Pi
Process 88099 resuming
Process 88099 stopped
// (5) At this point, Tiles repl already exit, and now we
// remove the SIGPIPE breakpoint
(lldb) process handle SIGPIPE --pass true --stop false --notify true

NAME         PASS   STOP   NOTIFY
===========  =====  =====  ======
SIGPIPE      true   false  true

// (6) and continue the Pi program after removing the breakpoint
(lldb) process continue
// We can see that Pi program exits as soon as it
// receives SIGPIPE
Process 88099 resuming
Process 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>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://www.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://www.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><li><p>Package the Tiles binary and the Python <a href="https://www.tiles.run/blog/move-along-python" target="_blank" rel="noopener noreferrer">venvstack</a> artifacts into one installer payload.</p></li></ul></li>
<li><p>Run the installer:</p><ul><li><p>Copy the Tiles binary and Python artifacts into the correct locations on disk.</p></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>Install file structure</h2>

<img src="https://www.tiles.run/blog-ship-it-up-pkgroot-structure.jpg" alt="" style="width: 100%; height: auto; margin: 2rem 0;" />

<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>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>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>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>Customizing the installer</h2>

<img src="https://www.tiles.run/blog-ship-it-up-installer-custom-1.jpg" alt="" style="width: 100%; height: auto; margin: 2rem 0;" />

<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 <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" style="font-weight:700">distribution</a> 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>&lt;welcome/&gt;</code> and <code>&lt;conclusion/&gt;</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>

<img src="https://www.tiles.run/blog-ship-it-up-installer-custom-2.jpg" alt="" style="width: 100%; height: auto; margin: 2rem 0;" />

<img src="https://www.tiles.run/blog-ship-it-up-installer-custom-3.jpg" alt="" style="width: 100%; height: auto; margin: 2rem 0;" />

<h2>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 style="white-space: pre-wrap">DEVELOPER  ID APPLICATION</code>.</p>

<h2>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>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>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://www.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://www.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 <a href="https://www.tiles.run/" target="_blank" rel="noopener noreferrer">Tiles</a>. 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.</p>

<h2>The Python Problem</h2>

<p>Right now, we have a polyglot architecture where the control pane 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>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 LM Studio 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>

<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</code></pre>

<p>This resolves dependencies and creates the necessary folders, lock files, and metadata for each layer.</p>

<p>Next:</p>

<pre><code>venvstacks build venvstacks.toml</code></pre>

<p>This builds the Python runtime and environments based on the lock files.</p>

<p>Finally:</p>

<pre><code>venvstacks publish venvstacks.toml</code></pre>

<p>This produces reproducible tarballs for each layer. These tarballs 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>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://www.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>