What it took to get a new package into nixpkgs with Claude Code
I wanted a second, low-stakes way to build up nixpkgs credibility for a client-facing consulting identity — something more substantial than an orphaned two-letter CLI tool, but not a multi-week undertaking either. I run AIOStreams (a Stremio addon, north of 2,500 GitHub stars) in production as a plain OCI container, and it turned out nobody had packaged it for nixpkgs at all. That's a decent shape of task: real software, real users, a codebase I already understood from running it, and a clear "not packaged yet" gap rather than a crowded space with an existing maintainer. I did the packaging with Claude Code end to end — this is what that actually looked like, including the parts where it didn't just work.
The mechanical part, which mostly just worked
AIOStreams is a pnpm monorepo — four workspace packages
(core, server, frontend,
a small extensions bundle), TypeScript throughout, Vite for the
frontend. Nixpkgs has good primitives for this now
(fetchPnpmDeps, pnpmConfigHook), and
Claude found and used them correctly on the first pass, pointed
at immich as reference since it's a similarly-shaped
package already in the tree. The fixed-output-derivation hash
dance — set lib.fakeHash, build, read the real hash
out of the error, repeat for both the source fetch and the pnpm
store fetch — is exactly the kind of tight, mechanical,
verifiable loop this tool is good at. No complaints there.
Where it stopped being mechanical
The friction showed up at the native-addon boundary. Two of
AIOStreams' dependencies — yencode (a usenet
decoder) and better-sqlite3 — compile C++ via
node-gyp, and getting that to work inside Nix's sandboxed,
network-isolated build took several genuinely wrong turns before
landing:
-
pnpm rebuildsilently did nothing. The natural command for "go build the native addons you skipped during--ignore-scriptsinstall" just no-op'd, no error, no output, nothing to grep for. It took reading node-gyp's actual source inside pnpm's bundled copy to learn why: pnpm ships its own node-gyp, and that copy doesn't translatenpm_config_nodedirinto its internal options the way the standalone nixpkgsnode-gyponPATHdoes — so it tried to download Node headers from nodejs.org, failed silently offline, and gave up. The fix was to stop delegating to pnpm for this step and justcdinto the dependency's directory and callnode-gyp rebuilddirectly. -
pnpm deploywanted a setting the frozen lockfile didn't have. The idiomatic way to produce a clean, prod-only install for one workspace package rejected itself with a lockfile/config mismatch. Rather than fight it, we dropped it and mirrored the project's own Dockerfile runtime stage instead — copy exactly thedistandnode_modulesdirectories it copies. Less elegant, more proven: it's literally what upstream ships. -
A version-metadata script shelled out to
git, which doesn't exist for afetchFromGitHubsource with no.gitdirectory — replaced with a hand-written file of the same shape.
None of these were hard once diagnosed. What they had in common is that the fix required reading the error, forming a real hypothesis about a specific tool's internals, and testing it — not pattern-matching against a thousand similar Stack Overflow questions, because there weren't a thousand similar questions.
The bug that only showed up on the branch that mattered
The package built cleanly, ran cleanly, and served real HTTP
traffic — against the nixpkgs release I run in production. Then
I went to actually open the PR against nixpkgs
master, which is what CI would build it against,
and the server crashed on startup with a native assertion
failure inside better-sqlite3's compiled addon.
The cause: nixpkgs master had already moved to a newer Node.js
24.x point release than the one my production channel pins, and
AIOStreams' locked better-sqlite3 version turned out
to be incompatible with it — a real, currently-open gap between
two pieces of software that don't know about each other, not a
packaging mistake. Nothing about my local build would ever have
surfaced this; it only exists on the exact branch a nixpkgs
reviewer would actually test against.
The instinctive fix — pin the derivation to the older, known-good
Node.js point release — turned out to be the wrong one on
inspection. Nixpkgs' Node builds carry a patch set that's
specific to each point release; the version I wanted and the
version master currently builds don't carry the
same patches, so overriding just the version number risked a
derivation that fails to build at all, for an unrelated reason,
on the next `nix-build`. We caught that by reading the two
versions' build recipes side by side before writing any code —
worth the five minutes it cost, since the alternative was
burning a multi-minute build cycle to discover it the hard way.
The fix that actually shipped: override the one dependency, not
the whole toolchain. Applied at fetch time, via a
postFetch hook that patches in a pnpm dependency
override and does a real network resolve inside the (network-
permitted) fixed-output derivation — so both the dependency
fetch and the main build see an already-correct, self-consistent
lockfile, rather than two derivations trying to independently
reconcile a change mid-build. Verified by deliberately
reproducing the crash with the old dependency first, then
confirming it was gone after.
What CI still caught after all that
After all of that, the PR's own CI failed anyway — on a linter
(nixpkgs-vet) requiring two flags,
strictDeps and __structuredAttrs, that
nixpkgs now expects on new packages and that neither of us had
reason to know about going in. Small fix, but it's a fair
reminder that "builds and runs for me" and "passes the actual
gate a maintainer will apply" are different bars, and the second
one is the one that counts.
What this says about the tool, not just the package
The parts of this that went fastest were the parts with a tight, legible feedback loop — build, read the exact error, fix, rebuild. The parts that took real judgment were the ones where two plausible fixes existed and only one of them was actually safe once you looked past the symptom: patch the one dependency that's actually broken, not the runtime underneath it just because that's where the stack trace pointed. That's not a knock on the tool — it did the diagnosis, ran the experiments, and surfaced the tradeoff clearly enough to decide on. But deciding which of two working fixes to actually ship is still a step a person does, and pretending otherwise is how you end up with a merged PR that quietly pins a toolchain version nobody can explain in six months.
The PR is NixOS/nixpkgs#552900, open as I write this. If it gets review comments, that'll probably be worth its own follow-up.
If you're weighing where an agent actually pays for itself versus where it just moves the same work somewhere less visible, I'm happy to talk it through.