❯ parkerjones.dev

Reproducible, Secret-Safe AI Agents with Nix Flakes, agenix, and Magentic-One

2025-02-07
Parker Jones and Claude Opus 4.8 in homelab
#nix , #agents , #ai , #agenix , #chainlit , #magentic-one and #chromadb
5 minute read

Most "I built an AI agent" posts skip the unglamorous parts: how the environment gets set up identically on another machine, where the API key actually lives, and what breaks when you try to make it reproducible. Those are the parts I find interesting, so this post is about the plumbing of Lab Agency — a multi-agent app wired together with Microsoft's Magentic-One — built on a Nix flake with secrets managed by agenix.

The agents are the fun part. Getting them to start the same way every time, with a decrypted key and the right Python deps, is the part that actually took the work.

The flake: one entry point, several jobs

The whole project is defined by a flake.nix with a few inputs and a flake-utils wrapper so it builds across systems:

inputs = {
  nixpkgs.url    = "github:NixOS/nixpkgs/nixos-unstable";
  flake-utils.url = "github:numtide/flake-utils";
  agenix.url     = "github:ryantm/agenix";
};

From there it exposes four targets, each a different way into the same project:

The point of doing it this way is that "clone and run" is true. There's no README step that says "first, set these five environment variables and install these packages." The flake target is the setup.

Secrets that decrypt themselves, in the shell

The piece I'm proudest of is how the OpenAI key is handled: it's never on disk in plaintext and never pasted into a shell. It lives age-encrypted at secrets/openai.txt, and the flake decrypts it into the environment as the shell starts up:

devShell = pkgs.mkShell {
  buildInputs = [ agenixCli pkgs.python3 ];
  shellHook = ''
    echo "Decrypting OpenAI secret..."
    export OPENAI_API_KEY=$(agenix --decrypt secrets/openai.txt \
      --identity ~/.ssh/parallaxis)

    if [ ! -d venv ]; then
      python3 -m venv venv
      source venv/bin/activate
      pip install --upgrade pip
      pip install -r requirements.txt
    else
      source venv/bin/activate
    fi
  '';
};

agenixCli here is just agenix.packages.${system}.default pulled from the input. Decryption keys off my SSH identity (~/.ssh/parallaxis), so the encrypted secret can sit in the repo and only someone holding the right key can read it. The encrypted file is safe to commit; the key never is.

The run-surfer target repeats the same decrypt-and-venv dance inside a writeShellScriptBin so the app launches with one command:

runSurfer = pkgs.writeShellScriptBin "run-surfer" ''
  # ...ensure venv, then:
  export OPENAI_API_KEY=$(agenix --decrypt secrets/openai.txt \
    --identity ~/.ssh/parallaxis)
  exec chainlit run clapp.py -w
'';

An honest caveat: there's a Python venv living inside a Nix flake here, which is not pure Nix and a purist would wince. I made that trade deliberately — the AI ecosystem moves fast and pinning everything through nixpkgs would mean fighting the toolchain instead of building the product. Nix gives me a reproducible shell (right Python, right agenix, right secret); pip install -r requirements.txt handles the fast-moving libraries. It's a pragmatic seam, not a principled one, and I'd reconsider it the moment the dependency set stabilizes.

Magentic-One's CLI does get the full Nix treatment, though — it's packaged in its own autogen-flake/magentic-one-cli.nix rather than left to pip.

The agents: a Magentic-One team plus a RAG layer

The app itself (clapp.py) is a Chainlit chat front-end over a Magentic-One group chat. Magentic-One ships a roster of specialist agents, and Lab Agency assembles them through AutoGen's extensions:

from autogen_agentchat.teams import MagenticOneGroupChat
from autogen_ext.agents.file_surfer import FileSurfer
from autogen_ext.agents.web_surfer import MultimodalWebSurfer
from autogen_ext.agents.magentic_one import MagenticOneCoderAgent
from autogen_ext.code_executors.local import LocalCommandLineCodeExecutor

So the team can read files (FileSurfer), browse the web (MultimodalWebSurfer), write code (MagenticOneCoderAgent), and execute it locally (LocalCommandLineCodeExecutor) — Magentic-One's orchestrator decides who does what for a given task.

On top of that roster I added two custom agents to give the team a memory. A KnowledgeAgent does retrieval-augmented generation against a Chroma collection:

async def retrieve_knowledge(self, query):
    query_embedding = self.embedding_function([query])[0]
    results = self.collection.query(
        query_embeddings=[query_embedding],
        n_results=5,
    )
    return results['documents'][0]

…and an EmbeddingAgent writes back to it, so research the team produces during a session can be folded into the knowledge base for the next one:

async def add_to_knowledge_base(self, content):
    embedding = self.embedding_function([content])[0]
    doc_id = f"doc_{self.collection.count()}"
    self.collection.add(documents=[content], ids=[doc_id],
                        embeddings=[embedding])
    return doc_id

The store itself is just a local chroma_db/chroma.sqlite3 — no managed vector database, no extra service to stand up. That keeps the whole thing runnable on a laptop, which was the goal.

Loading the knowledge base

The index-documents target runs a standalone script that chunks documents by token count using tiktoken, embeds them through AsyncOpenAI, and stores them in the agent_knowledge_base Chroma collection. Chunking on tokens rather than characters matters here — it's what keeps each chunk inside the embedding model's window instead of getting silently truncated, which is the kind of bug that doesn't error, it just quietly makes your retrieval worse.

What I'd carry forward

Stepping back, the parts of this project I'd reuse on the next one aren't the agents — those libraries will have moved on by next quarter. It's the scaffolding:

The honest roadmap item is the same one every project like this has: containerize it and put it somewhere it can run unattended. The flake makes that a smaller leap than it would otherwise be — the build is already declarative. The code, secrets-handling, and flake are all on GitHub: parallax-labs/lab-agency.

— Parker Jones, parkerjones.dev