<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>ptrck.sh</title>
    <subtitle>Patrick Pfenning — platform &amp; infrastructure engineer, homelab tinkerer.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://ptrck.sh/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://ptrck.sh"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-07-17T00:00:00+00:00</updated>
    <id>https://ptrck.sh/atom.xml</id>
    <entry xml:lang="en">
        <title>Building this site with an agent</title>
        <published>2026-07-17T00:00:00+00:00</published>
        <updated>2026-07-17T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ptrck.sh/blog/building-this-site/"/>
        <id>https://ptrck.sh/blog/building-this-site/</id>
        
        <content type="html" xml:base="https://ptrck.sh/blog/building-this-site/">&lt;p&gt;This site serves the same page four ways. A browser gets HTML. &lt;code&gt;curl&lt;&#x2F;code&gt; gets ANSI-coloured
text. &lt;code&gt;?format=md&lt;&#x2F;code&gt; gets the Markdown source, &lt;code&gt;?format=json&lt;&#x2F;code&gt; gets structured data. One URL,
one build, four representations.&lt;&#x2F;p&gt;
&lt;p&gt;I built it in a session with Claude Code. This is what actually happened, including the
parts that went badly, because the interesting content is in the failures rather than the
result.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-design-decision-that-mattered&quot;&gt;The design decision that mattered&lt;&#x2F;h2&gt;
&lt;p&gt;The obvious approach is to render each format from the Markdown source. Zola already turns
Markdown into HTML, so add a second renderer for ANSI and you are done.&lt;&#x2F;p&gt;
&lt;p&gt;That is wrong here, and it took a correction to see why. The body Markdown is not the page.
&lt;code&gt;title&lt;&#x2F;code&gt; and &lt;code&gt;description&lt;&#x2F;code&gt; live in front matter and get assembled by the templates. Feed
&lt;code&gt;content&#x2F;_index.md&lt;&#x2F;code&gt; straight to a renderer and you get output starting mid-sentence, with
no heading and no description. The template owns the composition, so anything that skips
the template renders something the site never publishes.&lt;&#x2F;p&gt;
&lt;p&gt;So the built HTML is the single source, and every other format derives from it:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;content&#x2F;*.md ──(zola)──&amp;gt; public&#x2F;**&#x2F;index.html&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                           ├─(pandoc)──(glow)──&amp;gt; index.ansi&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                           └─(glow --style notty)──&amp;gt; index.txt&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;content&#x2F;*.md ──(front matter stripped)────────────&amp;gt; index.md&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Markdown is the deliberate exception. It is copied from source rather than converted back
out of HTML, because the source is the real thing and a round-trip would be a lossy
reconstruction of something already on disk.&lt;&#x2F;p&gt;
&lt;p&gt;This is the same trick &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;style.ysap.sh&quot;&gt;style.ysap.sh&lt;&#x2F;a&gt; uses, inverted. That site
renders Markdown to ANSI first and derives its HTML from the ANSI, so the formats cannot
disagree. It also means its HTML &lt;em&gt;is&lt;&#x2F;em&gt; terminal-shaped, which is the point there and not
here. Same guarantee, opposite direction: one rendering pass, everything else a transform.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;colour-and-why-the-palette-is-not-mine-to-choose&quot;&gt;Colour, and why the palette is not mine to choose&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;curl style.ysap.sh&lt;&#x2F;code&gt; looks better than this site does in a terminal. It is worth being
precise about why.&lt;&#x2F;p&gt;
&lt;p&gt;That site emits 1254 absolute 256-colour codes and 20 background colours. The logo is
&lt;code&gt;48;5;223&lt;&#x2F;code&gt; — a hardcoded cream background. It looks great on a dark terminal and it does
not care what theme you use, because it brought its own. To replay that in a browser it
ships 1118 lines of CSS mapping the 256-colour palette to classes.&lt;&#x2F;p&gt;
&lt;p&gt;The alternative is to use only the 16 base colours. Those are not colours. They are slots.
&lt;code&gt;\e[35m&lt;&#x2F;code&gt; means “slot 5”, and the terminal decides what that is — which is what a theme
mostly &lt;em&gt;is&lt;&#x2F;em&gt;. Nord renders slot 5 as &lt;code&gt;#B48EAD&lt;&#x2F;code&gt;, Rose Pine as &lt;code&gt;#C4A7E7&lt;&#x2F;code&gt;, Solarized Light as
&lt;code&gt;#d33682&lt;&#x2F;code&gt;. Emit slot numbers, never set a background, and the page renders natively in
whatever theme the reader already chose. On a light terminal it stays readable, without
guessing.&lt;&#x2F;p&gt;
&lt;p&gt;The cost is real. Sixteen slots and no backgrounds cannot draw that logo. The site is
plainer than it could be. In exchange it does not fight anyone’s colour scheme, and the
choice belongs to the reader rather than to me.&lt;&#x2F;p&gt;
&lt;p&gt;The semantics are still mine — headings magenta, links cyan, URLs dimmed — but they are
expressed as slots, so the mapping to actual pixels happens in the terminal, after I am
done.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;The rendering tool silently dropped every colour.&lt;&#x2F;strong&gt; glow v2 emits &lt;code&gt;\e[;1m&lt;&#x2F;code&gt; — bold, with
an empty colour slot. Its own shipped styles do the same, so it is not a problem with our
style. glamour v2 moved to lipgloss v2, where &lt;code&gt;lipgloss.Color&lt;&#x2F;code&gt; no longer resolves numeric
strings like &lt;code&gt;&quot;5&quot;&lt;&#x2F;code&gt; or &lt;code&gt;&quot;252&quot;&lt;&#x2F;code&gt;, and every numeric colour quietly becomes nothing. The fix
was pinning glow to 1.x, capping Renovate below 2.0.0, and opening an issue so the pin has
an exit rather than rotting.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Local verification tested the wrong binaries, three times.&lt;&#x2F;strong&gt; The local glow was a source
build of unknown version and rendered correctly. The container pinned v2 and did not.
Alpine ships pandoc 0.1.1.1, which left the page navigation in the ANSI output; the local
pandoc was 3.10 and stripped it. Then CI tested a stale image, because the service pulled a
mutable tag and the runner reused a cached copy. Three separate bugs, one cause: “verified
locally” meant a different artifact than the one shipping. The fix is boring — pin every
tool version in &lt;code&gt;mise.toml&lt;&#x2F;code&gt; &lt;em&gt;and&lt;&#x2F;em&gt; the image, and test a pod built from the real image.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The tests passed on visibly broken output.&lt;&#x2F;strong&gt; At one point the page rendered entirely in
one grey. The suite went 15&#x2F;15 green, because it asserted “has escape codes” and “only uses
base-16” — both true of garbage. Tests that check the shape of the output rather than
whether it is right will confirm whatever you give them. Three assertions fixed it: no
empty colour slots, headings styled distinctly from body, more than one colour present.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The linter caught a bug in the tests.&lt;&#x2F;strong&gt; shellcheck flagged SC2314: in bats, &lt;code&gt;!&lt;&#x2F;code&gt; does not
fail a test unless it is the last command. Four negative assertions had been passing
unconditionally. Then the fix repeated the mistake — &lt;code&gt;run ! head file | grep&lt;&#x2F;code&gt; applies to
&lt;code&gt;head&lt;&#x2F;code&gt;, not the pipeline.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;One CSS-shaped detail took three attempts.&lt;&#x2F;strong&gt; &lt;code&gt;?format=md&lt;&#x2F;code&gt; returned
&lt;code&gt;application&#x2F;octet-stream&lt;&#x2F;code&gt;, because nginx &lt;code&gt;try_files&lt;&#x2F;code&gt; serves a file without re-running
location matching, and &lt;code&gt;.md&lt;&#x2F;code&gt; is not in &lt;code&gt;mime.types&lt;&#x2F;code&gt;. &lt;code&gt;default_type text&#x2F;plain&lt;&#x2F;code&gt; fixed it,
and deleting three now-dead &lt;code&gt;location&lt;&#x2F;code&gt; blocks came with it. Later a &lt;code&gt;types&lt;&#x2F;code&gt; block gave each
representation an honest content type — &lt;code&gt;text&#x2F;markdown&lt;&#x2F;code&gt;, &lt;code&gt;text&#x2F;plain+ansi&lt;&#x2F;code&gt; — after checking
that a &lt;code&gt;types&lt;&#x2F;code&gt; block merges with &lt;code&gt;mime.types&lt;&#x2F;code&gt; rather than replacing it and quietly breaking
CSS.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;on-working-with-an-agent&quot;&gt;On working with an agent&lt;&#x2F;h2&gt;
&lt;p&gt;The agent was fast at the mechanical work: scaffolding the chart, wiring nginx, writing the
bats suite, reading upstream source to find the lipgloss regression. That part is real and
it is not a small saving.&lt;&#x2F;p&gt;
&lt;p&gt;It was also confidently wrong on a regular basis. It diagnosed a Kafka crashloop as a
read-only filesystem problem when an identical StatefulSet ran fine on the other cluster.
It blamed &lt;code&gt;TERM&lt;&#x2F;code&gt; for the missing colours and was wrong twice before finding the version
mismatch. It told me to delete a PVC to wipe corrupt data on a storage class that
provisions by name and retains on delete, so the delete was a no-op and the corruption came
straight back — a fact documented in a comment in my own repo that it had read hours
earlier.&lt;&#x2F;p&gt;
&lt;p&gt;The pattern is consistent: it stops at the first plausible cause, and the first plausible
cause is often wrong. What broke that pattern was comparing against something known-good —
the other cluster, the other glow version, the same style on both binaries. Not more
reasoning. A reference point.&lt;&#x2F;p&gt;
&lt;p&gt;The useful division was: it does the work and holds the context, I decide what is true.
Every real correction in this session came from pushing back — the source-of-truth
inversion, the terminal-theme objection, running the thing in a real pod instead of
trusting a local run. The agent implemented all of those well. It proposed none of them.&lt;&#x2F;p&gt;
&lt;p&gt;It also wrote a first version of this page full of the phrases you would expect. That
needed removing too.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-it-landed&quot;&gt;Where it landed&lt;&#x2F;h2&gt;
&lt;p&gt;Four formats, one build, no backend. The negotiation is a &lt;code&gt;map&lt;&#x2F;code&gt; on the user agent, with
&lt;code&gt;?format=&lt;&#x2F;code&gt; as a deterministic override, because sniffing is a heuristic that breaks on
bots, HTTPie, and &lt;code&gt;curl | less&lt;&#x2F;code&gt;. Every file stays reachable directly. The container runs
nginx unprivileged on a read-only root filesystem. Eighteen tests run against the built
image in CI.&lt;&#x2F;p&gt;
&lt;p&gt;The plain version is one query parameter away, for anyone whose terminal disagrees with all
of this.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Hello, world</title>
        <published>2026-07-10T00:00:00+00:00</published>
        <updated>2026-07-10T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ptrck.sh/blog/hello-world/"/>
        <id>https://ptrck.sh/blog/hello-world/</id>
        
        <content type="html" xml:base="https://ptrck.sh/blog/hello-world/">&lt;p&gt;This is the first post on ptrck.sh. It’s a placeholder while the site takes shape, but
the plumbing underneath is real.&lt;&#x2F;p&gt;
&lt;p&gt;The site is a &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.getzola.org&#x2F;&quot;&gt;Zola&lt;&#x2F;a&gt; static build, served from a container
image built by GitLab CI and reconciled onto my homelab cluster with Flux — the same
GitOps flow as everything else I run. Source lives at
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;gitlab.com&#x2F;ptrck-sh&#x2F;website&quot;&gt;ptrck-sh&#x2F;website&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;More to come: notes on the homelab, the tooling, and whatever I’m breaking this week.&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
