<?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/">
  <channel>
    <title>notes / chris corbell</title>
    <link>https://chriscorbell.com/blog/</link>
    <atom:link href="https://chriscorbell.com/blog/rss.xml" rel="self" type="application/rss+xml" />
    <description>Occasional writing by Chris Corbell on software, systems, and the tools in between.</description>
    <language>en-us</language>
    <lastBuildDate>Mon, 14 Sep 2026 12:00:00 GMT</lastBuildDate>
    <item>
      <title>kardboard: a kanban board that summons coding agents</title>
      <link>https://chriscorbell.com/blog/posts/kardboard/</link>
      <guid>https://chriscorbell.com/blog/posts/kardboard/</guid>
      <description>Write a card, and about a minute later a coding agent clones the repo, does the work, and opens a pull request. My job is mostly reviewing now.</description>
      <content:encoded><![CDATA[<section class="sec sec-intro"><p>At some point I had a dozen coding agent threads open across a few projects, and I'd lost track of what each project still needed. Every thread had one task in it, but there was nowhere I could see the whole project at once. I was basically doing project management by scrolling back through chats.</p>
<p>I built kardboard to get out of that. It's a kanban board with six columns and one twist: about a minute after you create, edit, comment on, or move a card, an agent starts working on it.</p>
<figure class="figure"><img src="https://chriscorbell.com/assets/kardboard-board-CvUjPEss.png" width="2400" height="1353" class="bare" loading="lazy" decoding="async" alt="A kardboard board with its six columns, from Inbox to Done"><figcaption aria-hidden="true">A kardboard board with its six columns, from Inbox to Done</figcaption></figure>
</section><section class="sec" style="view-timeline-name: --sec-0"><h2 id="what-happens-when-you-save-a-card">What happens when you save a card</h2>
<p>A card has a Markdown description, comments, and eventually a pull request link and a preview URL. When you save one, kardboard waits a minute (so a quick second edit doesn't kick off a second session), then spins up a throwaway container. The container clones the repo onto a branch named after the card, then starts Claude Code or Codex with a workflow prompt and an MCP server. That MCP server lets the agent read every card, comment, and attachment on the board, and comment on, move, or create cards of its own.</p>
<p>The agent reads the request, does the work, runs the repo's acceptance command, pushes, and opens a pull request. Then it leaves one comment on the card and moves it to Review. If the request wasn't clear, it moves the card to Blocked and asks a question instead. That part turned out to be the most useful thing about it. A bad card gets a question back within a minute, before anyone spends twenty minutes on the wrong pull request.</p>
<figure class="figure"><img src="https://chriscorbell.com/assets/kardboard-card-DAEXnlC9.png" width="2400" height="1353" class="bare" loading="lazy" decoding="async" alt="A card in Review with the pull request, a preview link, and the Approve button"><figcaption aria-hidden="true">A card in Review with the pull request, a preview link, and the Approve button</figcaption></figure>
<p>When I hit Approve, kardboard squash-merges the PR, deletes the branch, and moves the card to Done. The agent also has a name and an avatar (Milo, by default). I thought that was a gimmick until I had more than one person on a board and needed to see at a glance who did what.</p>
</section><section class="sec" style="view-timeline-name: --sec-1"><h2 id="guardrails">Guardrails</h2>
<p>Agents can push branches, but they can never merge. Merging goes through a second GitHub app that only kardboard has, so even if a session goes completely off the rails, the worst it can do to the repo is open a pull request that a person still has to approve.</p>
<p>Approvals are tied to the exact commit on the pull request when you hit Approve. If anything gets pushed to the branch after you approve, the approval is thrown out and the card waits for you again.</p>
<p>The agent container never gets my provider credentials. An egress proxy adds my subscription token to requests on the way out. That gave me a bonus I didn't plan for: when my Claude subscription hits its usage limit, the proxy sees the rejection and the card gets picked back up on Codex. So the board keeps moving while I'm asleep.</p>
<p>Every session also has a CPU and memory limit, a time limit, and a repo token that expires after an hour. Each one gets its own network, too, and can't reach anything else on my LAN.</p>
</section><section class="sec" style="view-timeline-name: --sec-2"><h2 id="previews-and-bigger-requests">Previews and bigger requests</h2>
<p>With preview mode on, kardboard builds the branch's Dockerfile and hosts it at the card's own hostname. So if a card is about a form in the footer, it comes back with a link where you can actually go click on that form. Only board members can open it, and it gets torn down once the card hits Done.</p>
<p>If a request is too big for one pull request, it gets split into child cards. Each child starts its own session without anyone touching it, and the parent waits in Blocked. Once the children are done, the parent wakes back up and gets told which ones were merged and which were closed without changes.</p>
</section><section class="sec" style="view-timeline-name: --sec-3"><h2 id="whats-different-now">What's different now</h2>
<p>I still describe work in plain English, same as I used to write prompts. The difference shows up months later. If I want to know why a feature was built a certain way, I just open the card. The description, the questions, and the pull request are all still there. With a chat thread, that would all be gone.</p>
<p>It's live at kardboard.cc, invite-only. kardboard itself is managed on a kardboard board, and most of its pull requests come from Milo.</p>
</section>]]></content:encoded>
      <pubDate>Mon, 14 Sep 2026 12:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Pier: a desktop GUI for pi</title>
      <link>https://chriscorbell.com/blog/posts/pier/</link>
      <guid>https://chriscorbell.com/blog/posts/pier/</guid>
      <description>A macOS app for the pi coding agent. Every session is a real pi process, so everything you&#39;ve set up in the terminal still works.</description>
      <content:encoded><![CDATA[<section class="sec sec-intro"><p>pi is a coding agent that runs in the terminal, and I use it for most of my day. The problem is when I have four sessions going in four different projects. A terminal only shows one at a time, and the one I'm looking at is almost never the one that just finished or is waiting for me to say yes.</p>
<p>Pier is a macOS app I built for that. The sidebar lists every project pi has ever been run in, with its sessions underneath. If a session gets new output while I'm looking at something else, it gets an unread marker. If it's waiting on me, it gets a different marker. Either way, the Dock icon shows a badge. That's the feature I really wanted, and the rest of the app grew from there.</p>
<figure class="figure"><img src="https://chriscorbell.com/assets/pier-C9ymuQ5Y.png" width="2428" height="1736" class="bare" loading="lazy" decoding="async" alt="Pier showing a session transcript with the sidebar of projects and sessions"><figcaption aria-hidden="true">Pier showing a session transcript with the sidebar of projects and sessions</figcaption></figure>
</section><section class="sec" style="view-timeline-name: --sec-0"><h2 id="its-still-just-pi">It's still just pi</h2>
<p>Pier doesn't reimplement any part of pi. Every open session is a real pi process running in RPC mode, and Pier just displays what it sends back. Your models, settings, extensions, and slash commands all work, because it's literally the same program. You can quit Pier, run <code>pi -c</code> in the terminal, and pick up right where you left off.</p>
<p>The only things Pier stores are its own preferences, in <code>~/.pi/gui/settings.json</code>, and an error log next to it. Everything else stays in pi's config.</p>
</section><section class="sec" style="view-timeline-name: --sec-1"><h2 id="what-you-see">What you see</h2>
<p>The transcript streams in as pi writes it: markdown, thinking (collapsed by default), tool calls with their output showing up live, and edit diffs inline. The input box supports pi's slash commands, <code>@</code> file mentions, pasted images, and up-arrow history.</p>
<p>You can keep typing while a turn is running. Follow-up prompts go into a queue, and you can promote any of them to a steering message that interrupts the current turn. The header shows the git branch, and the model, thinking level, and a context meter sit under the input box.</p>
<p>Next to the transcript there's a read-only diff of the working tree against HEAD that updates as files change, plus a login shell in the project directory. Extension dialogs show up as sheets, and notifications show up as toasts.</p>
</section><section class="sec" style="view-timeline-name: --sec-2"><h2 id="installing-it">Installing it</h2>
<p>Download the DMG from the <a href="https://github.com/chriscorbell/pier/releases/latest">latest release</a> and drag Pier to Applications. It's signed and notarized, so it opens like any other app, and it checks for updates on its own and installs them when you say so. You'll need a Mac with Apple silicon on macOS 13 or newer, and pi 0.85 or newer on your PATH.</p>
</section>]]></content:encoded>
      <pubDate>Wed, 09 Sep 2026 12:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Running Qwen3.8-27B on an Intel Arc Pro B70</title>
      <link>https://chriscorbell.com/blog/posts/llm-server/</link>
      <guid>https://chriscorbell.com/blog/posts/llm-server/</guid>
      <description>A 27B model with a 128K context window on a 32 GB Intel GPU, serving the coding agents on my MacBook. What I measured, what I changed, and what broke.</description>
      <content:encoded><![CDATA[<section class="sec sec-intro"><p>I wanted the coding agents on my MacBook to use a model running on my own hardware. The Intel Arc Pro B70 made that affordable, since it has 32 GB of VRAM for a lot less than NVIDIA charges for the same amount of memory. The catch is that almost nobody runs inference on this card. I started from SergiioB's <a href="https://github.com/SergiioB/intel-arc-pro-b70-inference-cookbook">Intel Arc Pro B70 Inference Cookbook</a>, then tested every setting on my own machine and wrote down what changed.</p>
<p>The machine is a Threadripper 3970X with 64 GB of RAM, one B70, and Ubuntu Server 26.04 with the in-tree <code>xe</code> driver.</p>
</section><section class="sec" style="view-timeline-name: --sec-0"><h2 id="whats-running">What's running</h2>
<ul>
<li><strong>Model:</strong> Qwen3.8-27B, quantized to 4-bit (GPTQ INT4). The weights take about 18 GB, which leaves the rest of the card for context.</li>
<li><strong>Context:</strong> 131,072 tokens. The KV cache, which is the model's working memory for the conversation, is stored in FP8.</li>
<li><strong>Speculative decoding:</strong> the model's own draft head, also shrunk to INT4, guesses four tokens ahead, and the full model checks them.</li>
<li><strong>Engine:</strong> vLLM on Intel's XPU stack, one user at a time.</li>
</ul>
<dl class="stats"><div class="stat"><dt>decode, thinking off</dt><dd><span class="stat-num">87</span><span class="stat-unit">tok/s</span></dd></div><div class="stat"><dt>decode, thinking on</dt><dd><span class="stat-num">69</span><span class="stat-unit">tok/s</span></dd></div><div class="stat"><dt>uncached 126K-token prompt</dt><dd><span class="stat-num">146</span><span class="stat-unit">s</span></dd></div><div class="stat"><dt>the same prompt, cached</dt><dd><span class="stat-num">5.7</span><span class="stat-unit">s</span></dd></div></dl>
<p>With thinking on, which is how my agents actually use it, decode runs at about 69 tokens per second. The bigger number to watch is how long before the first token. A 126,000-token prompt the server hasn't seen before takes 146 seconds. That almost never happens in practice, because an agent resends the same growing conversation every turn and nearly all of it comes from the prefix cache. The same prompt a second time starts in under 6 seconds.</p>
</section><section class="sec" style="view-timeline-name: --sec-1"><h2 id="why-the-obvious-choice-was-wrong">Why the obvious choice was wrong</h2>
<p>The B70's matrix engines handle FP16, BF16, INT8, and INT4, but not FP8. On most cards, Qwen's official FP8 release would be the safe, low-loss pick. On this one, FP8 goes through a slower conversion path, and Intel's math library has an open bug about FP8 matrix math occasionally giving wrong results on these cards. So the weights are INT4.</p>
</section><section class="sec" style="view-timeline-name: --sec-2"><h2 id="quality-first-then-context-then-speed">Quality first, then context, then speed</h2>
<p>That order decided most of the config. For coding, I'd rather have a 27B model at INT4 than a smaller model at higher precision. Agents fill the context with the repo, so 128K was a must. Speed came last.</p>
<p>Context was the hard part. With a 16-bit KV cache, the card tops out around 110,000 tokens, because the speculative decoding buffers and the model's recurrent state come out of the same memory. An FP8 cache holds almost twice as many tokens. The cost is prompt processing time: about 15 percent slower at 31K tokens and 20 percent slower at 90K. Cached requests lose a bit too, since the FP8 cache works in bigger blocks and more of each prompt gets reprocessed. I took the trade.</p>
</section><section class="sec" style="view-timeline-name: --sec-3"><h2 id="what-helped-and-what-didnt">What helped, and what didn't</h2>
<p>Two changes made a real difference. Shrinking the draft head to INT4 sped up decode by 26 to 31 percent, with no quality loss on my test tasks. Guessing four tokens ahead instead of three added another 6.6 percent on identical code requests.</p>
<p>The rest didn't:</p>
<ul>
<li>Doubling the prefill chunk size didn't speed anything up and cost 17,000 tokens of cache. Prefill on this card is limited by the work per token, not by how tokens are grouped.</li>
<li>Pinning the process to one CPU core complex made no measurable difference.</li>
<li>vLLM 0.29.0 passed every check but wasn't faster, needed four patches, and held 1,400 fewer tokens of cache. I stayed on the pinned image.</li>
<li>llama.cpp's SYCL backend with a Q6_K build worked at 128K, but decoded at 34 tok/s with thinking on, about half of vLLM.</li>
</ul>
</section><section class="sec" style="view-timeline-name: --sec-4"><h2 id="why-not-just-run-it-on-the-mac">Why not just run it on the Mac</h2>
<p>I ran the same base model on my M5 Pro MacBook with Inco's Splash and sent both machines identical requests. The B70 decoded at 91 tok/s to the Mac's 63. The bigger gap is prompt processing: an uncached 32K prompt took 21 seconds on the B70 and 102 on the Mac. The Mac was faster on cached prompts, 0.27 seconds vs 2.4, but under sustained load it got hot enough to slow down.</p>
</section><section class="sec" style="view-timeline-name: --sec-5"><h2 id="what-broke">What broke</h2>
<ul>
<li><strong>The engine crashed at startup</strong> with <code>opendir failed: could not open device directory</code> until I bind-mounted <code>/dev/dri</code> into the container. Mapping it as a device isn't enough, even with one GPU.</li>
<li><strong>Level Zero rejects any single allocation over 4 GiB</strong> by default, and the KV cache is bigger than that. Setting <code>UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS=1</code> fixes it.</li>
<li><strong>The usual GPU monitors don't work.</strong> <code>intel_gpu_top</code> and <code>btop</code>'s Intel collector are both hard-coded to the old i915 driver. <code>sudo gputop</code> works with <code>xe</code>.</li>
<li><strong>Thinking comes back in <code>message.reasoning</code></strong>, not <code>message.reasoning_content</code>. A client that only reads the older field shows no thinking at all.</li>
<li><strong>The GPU faults every so often</strong>, so a watchdog catches it, saves diagnostics, and restarts the engine. My first version had a regex bug that matched any log line, so it kept restarting a healthy server over unrelated network messages. The fixed version only reacts to real GPU faults. It passes its tests but hasn't caught a real one yet.</li>
</ul>
</section><section class="sec" style="view-timeline-name: --sec-6"><h2 id="the-client-side">The client side</h2>
<p>The repo also has the MacBook side: tested configs for Claude Code, Codex, OpenCode, and Hermes. Claude Code doesn't need a proxy, because vLLM serves Anthropic's Messages API directly. It needs three settings: the key sent as a bearer token, an effort of low, medium, or xhigh (plain &quot;high&quot; gets a 500), and the context limit set explicitly, or it assumes 200K. My pi config lives in <a href="https://github.com/chriscorbell/pi-config">its own repo</a>.</p>
</section>]]></content:encoded>
      <pubDate>Mon, 07 Sep 2026 12:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Kino: a Stremio client I can keep extending</title>
      <link>https://chriscorbell.com/blog/posts/kino/</link>
      <guid>https://chriscorbell.com/blog/posts/kino/</guid>
      <description>My own Stremio client with an interface built for a TV remote, native playback, and features I&#39;ve always wanted, starting with skip intro.</description>
      <content:encoded><![CDATA[<section class="sec sec-intro"><p>The official Stremio apps work fine, and I still use them. But whenever I wanted them to do something they didn't do, I got stuck. I wanted intros to skip automatically. I wanted an interface that actually works well with a TV remote. I wanted playback to behave the way I watch things. You can't add any of that from outside the app. So I built Kino, a Stremio client I own, and now I can add whatever I want.</p>
</section><section class="sec" style="view-timeline-name: --sec-0"><h2 id="same-core-new-interface">Same core, new interface</h2>
<p>Kino runs on the actual Stremio Core, the same library the official apps use. So add-ons, catalogs, your library, and watch progress all work the same as they do everywhere else. Everything you see on screen is new, though.</p>
<p>On macOS it's a native Qt app. The screens are built with web tech, but playback is handled by libmpv with VideoToolbox hardware decoding. Media keys and Now Playing work, and the screen only stays awake while a video is playing. Subtitles, whether they're embedded or come from an add-on, have controls for delay, size, and position.</p>
<p>On the TV it's a native Kotlin and Compose app running on an NVIDIA Shield, so moving around with the remote works the way it should. Near the end of an episode it shows Up Next, and it waits until your progress is saved before it loads sources for the next episode.</p>
</section><section class="sec" style="view-timeline-name: --sec-1"><h2 id="keeping-core-updates-from-breaking-things">Keeping Core updates from breaking things</h2>
<p>On the Mac, everything that comes out of the Core goes through a validating adapter before any screen sees it, so the screens only get navigation requests and playback fields. Those adapters are tested against a pinned version of the Core using committed fixtures, on every pull request. If a Core update changes the shape of the data, a test fails instead of a screen breaking.</p>
</section><section class="sec" style="view-timeline-name: --sec-2"><h2 id="skip-intro">Skip intro</h2>
<p>This is the feature that started the whole thing. Kino finds intro markers, either embedded in the file or from the community, shows them on the timeline, and skips them when you press a button or automatically, with an undo. I'd wanted this in a Stremio client for years, and I couldn't add it to one that wasn't mine.</p>
<p>Development started on macOS, and the Android TV app came next. Windows and Linux are after that. For now, the macOS build is ad-hoc signed and only runs on Apple silicon with macOS 26.</p>
</section>]]></content:encoded>
      <pubDate>Sun, 23 Aug 2026 12:00:00 GMT</pubDate>
    </item>
    <item>
      <title>invox: invoicing that lives in a container</title>
      <link>https://chriscorbell.com/blog/posts/invox/</link>
      <guid>https://chriscorbell.com/blog/posts/invox/</guid>
      <description>A personal invoice generator backed by a SQLite file, with PDF export. My banking details live in the database instead of the code, so the code can be public.</description>
      <content:encoded><![CDATA[<section class="sec sec-intro"><p>I needed to send invoices, and I didn't love either of the usual options. A spreadsheet makes ugly PDFs and doesn't keep any history. An invoicing service wanted a monthly fee to manage way more than I needed. I just wanted a small app that makes an invoice, remembers it, and exports a PDF that looks right.</p>
<p>So I built invox. You fill out the invoice in the browser, it gets saved to a SQLite file, and the editor is laid out like the PDF you download. The frontend is React and TypeScript, the API is Hono, and pdf-lib generates the PDF.</p>
</section><section class="sec" style="view-timeline-name: --sec-0"><h2 id="keeping-my-bank-details-out-of-git">Keeping my bank details out of git</h2>
<p>The stuff that identifies me on an invoice, my ACH payment details, and the footer text are all set on a Settings page and stored in the database. None of it is in the repo. That way I can share the code and anyone can run it, and nobody's bank details end up in git history.</p>
</section><section class="sec" style="view-timeline-name: --sec-1"><h2 id="running-it">Running it</h2>
<p>CI builds a new image on every green push to <code>main</code>, tagged with <code>latest</code> and the commit hash. All the data is in one SQLite database, so backing up means copying the data folder, and rolling back means pinning an older tag. For something that deals with money, I wanted both of those to be as boring as possible.</p>
</section>]]></content:encoded>
      <pubDate>Tue, 28 Jul 2026 12:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Brutal Doom in the browser, booting straight into the map</title>
      <link>https://chriscorbell.com/blog/posts/webdoom/</link>
      <guid>https://chriscorbell.com/blog/posts/webdoom/</guid>
      <description>A fork of a UZDoom WebAssembly port that boots straight into one game when the page loads. Open the URL on a TV and you&#39;re in MAP01.</description>
      <content:encoded><![CDATA[<section class="sec sec-intro"><p>There's a WebAssembly port of UZDoom that runs in the browser, and it's really good. It has a picker where you upload an IWAD, add mods, and hit Launch. I wanted something simpler: open the browser on my TV and have Doom 2 with Brutal Doom already starting up.</p>
<p>So my fork is a container that you point at a folder of WADs, and it boots one game.</p>
<figure class="figure"><img src="https://chriscorbell.com/assets/webdoom-HtIsaHE1.webp" width="1524" height="836" loading="lazy" decoding="async" alt="Brutal Doom running in the browser"><figcaption aria-hidden="true">Brutal Doom running in the browser</figcaption></figure>
</section><section class="sec" style="view-timeline-name: --sec-0"><h2 id="boot-config">Boot config</h2>
<p>The fork adds a <code>boot.json</code> file next to the WADs:</p>
<div class="code" data-lang="json"><div class="code-head"><span class="code-label">json</span></div><pre class="shiki signal" style="background-color:#171716;color:#a9a79e" tabindex="0"><code><span class="line"><span style="color:#8A897F">{</span><span style="color:#8A897F"> "</span><span style="color:#FF5F52">iwad</span><span style="color:#8A897F">"</span><span style="color:#8A897F">:</span><span style="color:#A9A79E"> "</span><span style="color:#ECEBE4">doom2.wad</span><span style="color:#A9A79E">"</span><span style="color:#8A897F">,</span><span style="color:#8A897F"> "</span><span style="color:#FF5F52">file</span><span style="color:#8A897F">"</span><span style="color:#8A897F">:</span><span style="color:#A9A79E"> "</span><span style="color:#ECEBE4">brutal22test6.pk3</span><span style="color:#A9A79E">"</span><span style="color:#8A897F">,</span><span style="color:#8A897F"> "</span><span style="color:#FF5F52">warp</span><span style="color:#8A897F">"</span><span style="color:#8A897F">:</span><span style="color:#FFAB9F"> 1</span><span style="color:#8A897F">,</span><span style="color:#8A897F"> "</span><span style="color:#FF5F52">skill</span><span style="color:#8A897F">"</span><span style="color:#8A897F">:</span><span style="color:#FFAB9F"> 4</span><span style="color:#8A897F"> }</span></span></code></pre></div>
<p>With that in place, going to the address loads Doom 2 with Brutal Doom and drops you right into MAP01 on Ultra-Violence, no menu. The first visit downloads the WADs and shows the progress. After that they're stored in the browser's IndexedDB, so the game starts right away. If you drop another file in the mounted folder, you can load it by name without registering it anywhere.</p>
<p>A URL that names an IWAD skips the file entirely, so a shared link like <code>?iwad=doom2.wad&amp;file=brutal22test6.pk3&amp;warp=7</code> still works on a kiosk setup. Every parameter gets checked against a strict pattern before it gets anywhere near the engine, and anything that doesn't match gets thrown out.</p>
</section><section class="sec" style="view-timeline-name: --sec-1"><h2 id="the-bug-i-fixed">The bug I fixed</h2>
<p>Upstream ships with Brutal Doom's damage overlays turned off, because they were covering the whole screen in almost solid red. The cause turned out to be a HUD message limit that was applied per font. Brutal Doom draws damage using about twenty different graphics over the same pixels, so even with a limit of four per font, you could still end up with around eighty translucent layers stacked on top of each other. Applying one limit across all of them fixed it, and the overlays are back on.</p>
</section><section class="sec" style="view-timeline-name: --sec-2"><h2 id="two-settings-that-will-break-it">Two settings that will break it</h2>
<p>WebAssembly threads need <code>SharedArrayBuffer</code>, and browsers only allow that on pages that are cross-origin isolated and served from a secure context. If either one is missing, the engine's threads never start.</p>
<ul>
<li>The bundled Caddy sets <code>Cross-Origin-Opener-Policy: same-origin</code> and <code>Cross-Origin-Embedder-Policy: require-corp</code>. If you put your own reverse proxy in front of it, make sure both headers reach the browser unchanged.</li>
<li>It has to be served over HTTPS. A plain HTTP address on your LAN doesn't count as a secure context, so the engine won't start. Plain HTTP only works on <code>localhost</code>.</li>
</ul>
<p>The fork also builds on Linux and macOS, while upstream's helper tools only came as Windows binaries. Other than Freedoom, WADs aren't included, so you'll need to bring your own.</p>
</section>]]></content:encoded>
      <pubDate>Tue, 28 Jul 2026 12:00:00 GMT</pubDate>
    </item>
    <item>
      <title>camview: a low-latency camera viewer in one container</title>
      <link>https://chriscorbell.com/blog/posts/camview/</link>
      <guid>https://chriscorbell.com/blog/posts/camview/</guid>
      <description>Point it at an RTSP camera and it shows the live feed in your browser over WebRTC. If the camera doesn&#39;t send H.264, it transcodes on the fly.</description>
      <content:encoded><![CDATA[<section class="sec sec-intro"><p>The apps that come with IP cameras are laggy. There's enough delay between what the camera sees and what shows up on screen that anything moving is always a step behind. I built camview to fix that. You open it in a browser, it connects to the camera's RTSP feed, and it shows it live with about as little latency as a browser can manage.</p>
</section><section class="sec" style="view-timeline-name: --sec-0"><h2 id="webrtc-with-a-fallback">WebRTC, with a fallback</h2>
<p>The video comes in over WebRTC, which is the fastest option a browser has. If WebRTC can't connect, the player falls back to Media Source Extensions over the same port. That's fine for keeping an eye on a hallway, but you'll notice the delay on anything that moves.</p>
<p>Inside the container, go2rtc pulls the RTSP stream and can serve it either way. nginx sits in front so the page and the signaling socket can share one port:</p>
<figure class="figure figure-diagram"><div class="diagram" role="img" aria-label="camview's two paths: the page and signaling go through nginx on :8080, the video comes straight from go2rtc on :8555"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 280" width="820" height="280" fill="none" font-family="JetBrains Mono, ui-monospace, monospace">
  <defs>
    <marker id="cv-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
      <path d="M0 0L10 5L0 10z" fill="currentColor"/>
    </marker>
    <marker id="cv-arrow-red" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
      <path d="M0 0L10 5L0 10z" fill="#f04438"/>
    </marker>
  </defs>

  <rect x="236.5" y="30.5" width="410" height="150" stroke="currentColor" stroke-opacity="0.45" stroke-dasharray="4 5"/>
  <text x="252" y="54" font-size="12" fill="currentColor" fill-opacity="0.6">camview container</text>

  <rect x="20.5" y="86.5" width="130" height="56" stroke="currentColor"/>
  <text x="85.5" y="120" font-size="15" font-weight="700" fill="currentColor" text-anchor="middle">browser</text>

  <rect x="262.5" y="86.5" width="140" height="56" stroke="currentColor"/>
  <text x="332.5" y="112" font-size="15" font-weight="700" fill="currentColor" text-anchor="middle">nginx</text>
  <text x="332.5" y="130" font-size="12" fill="currentColor" fill-opacity="0.6" text-anchor="middle">:8080</text>

  <rect x="482.5" y="86.5" width="140" height="56" stroke="currentColor"/>
  <text x="552.5" y="112" font-size="15" font-weight="700" fill="currentColor" text-anchor="middle">go2rtc</text>
  <text x="552.5" y="130" font-size="12" fill="currentColor" fill-opacity="0.6" text-anchor="middle">:1984, :8555</text>

  <rect x="690.5" y="86.5" width="110" height="56" stroke="currentColor"/>
  <text x="745.5" y="120" font-size="15" font-weight="700" fill="currentColor" text-anchor="middle">camera</text>

  <path d="M151 114.5H258" stroke="currentColor" marker-end="url(#cv-arrow)"/>
  <text x="189" y="104" font-size="11" fill="currentColor" fill-opacity="0.7" text-anchor="middle">page + ws</text>
  <path d="M403 114.5H478" stroke="currentColor" marker-end="url(#cv-arrow)"/>
  <text x="440.5" y="104" font-size="11" fill="currentColor" fill-opacity="0.7" text-anchor="middle">signaling</text>

  <path d="M690 114.5H627" stroke="currentColor" marker-end="url(#cv-arrow)"/>
  <text x="670" y="104" font-size="11" fill="currentColor" fill-opacity="0.7" text-anchor="middle">rtsp</text>

  <path d="M552.5 143V232.5H85.5V147" stroke="#f04438" stroke-width="2" marker-end="url(#cv-arrow-red)"/>
  <text x="323.5" y="222" font-size="12" font-weight="700" fill="#f04438" text-anchor="middle">webrtc media, :8555</text>
  <text x="323.5" y="258" font-size="11" fill="currentColor" fill-opacity="0.6" text-anchor="middle">h.264, transcoded by ffmpeg when the browser can't play what the camera sends</text>
</svg></div><figcaption aria-hidden="true">camview's two paths: the page and signaling go through nginx on :8080, the video comes straight from go2rtc on :8555</figcaption></figure>
</section><section class="sec" style="view-timeline-name: --sec-1"><h2 id="works-with-any-camera">Works with any camera</h2>
<p>A lot of cameras send H.265 or some other format browsers won't play. camview transcodes those to H.264 on the fly with ffmpeg, so any camera works in any browser. If you set <code>HWACCEL</code> to <code>vaapi</code> or <code>cuda</code> and pass your GPU through, the transcoding runs on the GPU instead of the CPU. And if your camera already has an H.264 sub-stream, you can set <code>TRANSCODE=off</code> and just use that.</p>
</section><section class="sec" style="view-timeline-name: --sec-2"><h2 id="the-setting-thats-easy-to-miss">The setting that's easy to miss</h2>
<p>When the container is on a bridge network, it can't figure out an address the browser can actually reach, so you have to tell it. Set <code>WEBRTC_CANDIDATE</code> to the server's LAN address and port. If you leave it blank, everything still works, but it quietly drops to the slower fallback. So if the feed seems about a second behind, check this first.</p>
<div class="code" data-lang="yaml"><div class="code-head"><span class="code-label">yaml</span></div><pre class="shiki signal" style="background-color:#171716;color:#a9a79e" tabindex="0"><code><span class="line"><span style="color:#FF5F52">services</span><span style="color:#8A897F">:</span></span>
<span class="line"><span style="color:#FF5F52">  camview</span><span style="color:#8A897F">:</span></span>
<span class="line"><span style="color:#FF5F52">    image</span><span style="color:#8A897F">:</span><span style="color:#ECEBE4"> ghcr.io/chriscorbell/camview:latest</span></span>
<span class="line"><span style="color:#FF5F52">    ports</span><span style="color:#8A897F">:</span></span>
<span class="line"><span style="color:#8A897F">      -</span><span style="color:#A9A79E"> "</span><span style="color:#ECEBE4">3147:8080</span><span style="color:#A9A79E">"</span></span>
<span class="line"><span style="color:#8A897F">      -</span><span style="color:#A9A79E"> "</span><span style="color:#ECEBE4">8555:8555/tcp</span><span style="color:#A9A79E">"</span></span>
<span class="line"><span style="color:#8A897F">      -</span><span style="color:#A9A79E"> "</span><span style="color:#ECEBE4">8555:8555/udp</span><span style="color:#A9A79E">"</span></span>
<span class="line"><span style="color:#FF5F52">    environment</span><span style="color:#8A897F">:</span></span>
<span class="line"><span style="color:#FF5F52">      CAMERA_RTSP_URL</span><span style="color:#8A897F">:</span><span style="color:#ECEBE4"> ${CAMERA_RTSP_URL}</span></span>
<span class="line"><span style="color:#FF5F52">      WEBRTC_CANDIDATE</span><span style="color:#8A897F">:</span><span style="color:#ECEBE4"> ${WEBRTC_CANDIDATE:-}</span></span>
<span class="line"><span style="color:#FF5F52">      TRANSCODE</span><span style="color:#8A897F">:</span><span style="color:#ECEBE4"> ${TRANSCODE:-h264}</span></span>
<span class="line"><span style="color:#FF5F52">      HWACCEL</span><span style="color:#8A897F">:</span><span style="color:#ECEBE4"> ${HWACCEL:-}</span></span></code></pre></div>
<aside class="callout callout-warning"><p class="callout-label">heads up</p>
<p>There's no authentication, so keep it on your LAN. When I want to check my cameras while I'm away, I use a VPN.</p>
</aside>
<p>All I really wanted was a live view of my own cameras on my phone or laptop, without going through some vendor's app.</p>
</section>]]></content:encoded>
      <pubDate>Sun, 07 Jun 2026 12:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Andromeda: a public TV channel with a schedule and a chat</title>
      <link>https://chriscorbell.com/blog/posts/andromedatv/</link>
      <guid>https://chriscorbell.com/blog/posts/andromedatv/</guid>
      <description>A livestream page for my media library. Anyone with the link can see what&#39;s on, what&#39;s next, and chat about it. The whole thing runs in one container.</description>
      <content:encoded><![CDATA[<section class="sec sec-intro"><p>ErsatzTV takes a media library and turns it into a TV channel. It picks what plays next and just keeps going, so there's always something on. I liked that a lot, and I wanted other people to be able to watch it with me. But a raw stream is missing two things you need for that: a schedule, so you know if it's worth sticking around, and a chat, so you can talk about what's on.</p>
<p>Andromeda adds both. When you open the page, the channel is already playing. Next to the player you can see what's on now and what's coming up, with the chat right under it.</p>
</section><section class="sec" style="view-timeline-name: --sec-0"><h2 id="everything-runs-in-one-process">Everything runs in one process</h2>
<p>The whole app is one Node process on one origin:</p>
<div class="code" data-lang="text"><div class="code-head"></div><pre class="shiki signal" style="background-color:#171716;color:#a9a79e" tabindex="0"><code><span class="line"><span>/               the page</span></span>
<span class="line"><span>/api/chat/*     chat, with a server-sent-events stream for live messages</span></span>
<span class="line"><span>/api/schedule   the schedule, normalized</span></span>
<span class="line"><span>/iptv/*         a reverse proxy to ErsatzTV</span></span></code></pre></div>
<p>Honestly, that layout is most of why I built this. The player, schedule, chat, and stream proxy all fit in one container, so deploying it is a single compose service, and if you want to know how requests get routed, you can just read one file.</p>
<p>The schedule comes from ErsatzTV's guide, and the server cleans it up before sending it to the browser. If you give it a Jellyfin key, it adds release years and air dates too. The IPTV proxy rewrites the playlist as it passes through, so the stream still works behind any public hostname, as long as <code>PUBLIC_APP_ORIGIN</code> is set.</p>
</section><section class="sec" style="view-timeline-name: --sec-1"><h2 id="the-chat-doesnt-stick-around">The chat doesn't stick around</h2>
<p>Accounts are just a nickname and a password. Messages max out at 500 characters, and the server only keeps the last 100. That's intentional. TV doesn't have scrollback, so I didn't think the chat next to it should either. There's also a small admin side for warning people, deleting messages, and banning.</p>
<p>The frontend is React, TypeScript, Vite, and Tailwind. The backend is a small Express app with SQLite and its own tests. I use Bun for development and Node 24 in production. It's been running for months now, and people I know drop in when something good is on the schedule.</p>
</section>]]></content:encoded>
      <pubDate>Sun, 01 Mar 2026 12:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Orbitdash: a server dashboard I can edit</title>
      <link>https://chriscorbell.com/blog/posts/orbitdash/</link>
      <guid>https://chriscorbell.com/blog/posts/orbitdash/</guid>
      <description>A homelab dashboard with live CPU, memory, and disk stats right next to my service links, and you edit all of it from the browser.</description>
      <content:encoded><![CDATA[<section class="sec sec-intro"><p>I used Homer and then Homarr as the start page for my homelab. Both are basically a list of links, and both keep that list in a YAML file. Adding a service meant opening the file, editing it, and redeploying, which is a weird amount of work just to add one URL.</p>
<p>Orbitdash is the dashboard I actually wanted to look at every day. The links are stored in a SQLite database, and you add and edit them right in the browser. Live CPU, memory, and disk stats are on the same page, so I don't keep a Grafana tab open anymore just to see if the box is busy.</p>
<figure class="figure"><img src="https://chriscorbell.com/assets/orbitdash-DvC4a6MB.png" width="1600" height="1202" class="bare" loading="lazy" decoding="async" alt="Orbitdash showing service links grouped by category, with CPU, RAM, and disk stats and a metrics chart below"><figcaption aria-hidden="true">Orbitdash showing service links grouped by category, with CPU, RAM, and disk stats and a metrics chart below</figcaption></figure>
</section><section class="sec" style="view-timeline-name: --sec-0"><h2 id="day-to-day">Day to day</h2>
<p>Each service gets an icon, a name, a short description, and a category. You can upload a PNG or SVG for the icon, or paste a URL and Orbitdash will grab it. You can drag categories to reorder them in settings and click to collapse them, and your browser remembers which ones are collapsed. There's a search box for when the list gets long, and you can switch between a 3, 4, or 5 column grid depending on your screen.</p>
<p>The metrics panel shows the last 30 seconds as a chart, with tabs for CPU, RAM, and disk.</p>
</section><section class="sec" style="view-timeline-name: --sec-1"><h2 id="running-it">Running it</h2>
<p>It's one container with one volume:</p>
<div class="code" data-lang="yaml"><div class="code-head"><span class="code-label">yaml</span></div><pre class="shiki signal" style="background-color:#171716;color:#a9a79e" tabindex="0"><code><span class="line"><span style="color:#FF5F52">services</span><span style="color:#8A897F">:</span></span>
<span class="line"><span style="color:#FF5F52">  orbitdash</span><span style="color:#8A897F">:</span></span>
<span class="line"><span style="color:#FF5F52">    image</span><span style="color:#8A897F">:</span><span style="color:#ECEBE4"> ghcr.io/chriscorbell/orbitdash:latest</span></span>
<span class="line"><span style="color:#FF5F52">    restart</span><span style="color:#8A897F">:</span><span style="color:#ECEBE4"> unless-stopped</span></span>
<span class="line"><span style="color:#FF5F52">    ports</span><span style="color:#8A897F">:</span></span>
<span class="line"><span style="color:#8A897F">      -</span><span style="color:#A9A79E"> "</span><span style="color:#ECEBE4">7770:3000</span><span style="color:#A9A79E">"</span></span>
<span class="line"><span style="color:#FF5F52">    volumes</span><span style="color:#8A897F">:</span></span>
<span class="line"><span style="color:#8A897F">      -</span><span style="color:#ECEBE4"> ./orbitdash-data:/data</span></span></code></pre></div>
<p>It runs as uid 1000, so if Docker creates the folder as root, run <code>chown -R 1000:1000 ./orbitdash-data</code> once.</p>
<p>The database and uploaded icons are all stored in that volume, so backing up is just stopping it and copying one folder. Images are built for x86_64 and aarch64. Every push to main gets its own tag alongside <code>latest</code>, and releases get version tags like <code>v0.2.0</code>, if you want to pin one.</p>
<p>It's built with TypeScript, React, Tailwind, shadcn/ui, Hono, Bun, and SQLite. My favorite part is that I haven't had to open a config file since.</p>
</section>]]></content:encoded>
      <pubDate>Sun, 15 Feb 2026 12:00:00 GMT</pubDate>
    </item>
  </channel>
</rss>
