What I built
A pipeline that turns raw material into a private, cited knowledge wiki, and then promotes chosen pages from it into this public site. The raw material is whatever I already have: LLM conversations, code, articles, notes.
The problem is specific. A portfolio that lists what someone built says very little to a security hiring manager; the signal is why a decision was made and what was rejected. Most of that reasoning already happens in conversations with an LLM, and then it evaporates. This system captures those conversations as durable sources, compiles them into decision records and concept explainers, and publishes a curated subset.
The page you are reading was produced by that pipeline, from the conversation in which the pipeline was designed.
flowchart LR
subgraph private["homelab-wiki (private)"]
raw["Source material<br/>chat logs · code · notes"]
wiki["wiki/<br/>concepts · projects · decisions"]
raw -->|ingest| wiki
end
subgraph public["portfolio-site (public)"]
site["src/content/projects/"]
end
wiki -->|publish| site
site -->|git push| cf["Cloudflare Pages"]
The workflow calls for review of wiki changes and a separate review of public output. Remote enforcement depends on repository permissions and deployment settings.
Why — the decisions
A good part of the work here was deciding what not to build.
Where the knowledge lives
I kept everything as Markdown in Git rather than in a database or a hosted wiki. That makes the diff the unit of review, and it means the pull request is the human-in-the-loop gate. Audit trail and rollback come for free, and CI can gate the merge instead of being bolted on beside it.
I had originally planned an agent-level interrupt as the approval mechanism. A pull request provides a familiar review record. Preventing bypass also requires branch protection and appropriately limited credentials.
One repo or two
I considered a single repo with pages filtered by a visibility flag, two
separate repos, and a Git submodule arrangement.
The single-repo version is much less to build and needs no promotion step, but it puts raw transcripts in the same repository that serves a public site, so one misconfiguration exposes everything. I rejected submodules outright: they leak the existence and structure of the private content, and they are painful to operate.
I chose two repos. One private, holding raw sources, the wiki, and the agent’s instructions. One public, this one, holding only what I explicitly publish. Promotion is opt-in per page and defaults to private, so the fail-closed state is the safe one. Never “publish everything not explicitly hidden”.
The cost is that it creates a promotion boundary I then had to design, and it splits one review into two with genuinely different questions: an accuracy gate on the private side (“is this correct?”) and a disclosure gate before anything goes public (“is this safe for a stranger to read?”). Two checklists, not one.
What I deliberately left out
The obvious build includes a vector store and a search service: embed every page, retrieve by similarity, let the agent query the corpus. That can be useful when retrieval quality and reading costs justify the operational overhead. A personal one lives in the tens to low hundreds, where an agent can simply read the pages it needs. So the vector store, the search service, and the gateway around them are all omitted.
I wrote the threshold down rather than leaving it implicit: revisit the decision at roughly 150 pages. It is a judgement call and not a measured limit, but an unwritten threshold is one nobody ever checks.
The cost is real and I would rather name it than bury it. This implementation has no automated semantic duplicate check. A vector service is one possible implementation; simpler text-based checks are also possible.
Skills now, a state machine later
Each operation is a skill: a prompt the agent follows, not a running service.
A LangGraph compile pipeline was considered for resumable execution and explicit stages. It remains deferred. Building a state machine before knowing what a good page looks like produces a worse state machine, and I did not yet know the target shape. So the first milestone was compile, review, and publish working end to end, with no backend built at all.
The constraint I put on any future version is that it stays invoked, never a daemon. Manual invocation keeps operation simple. Scheduled drafting could still preserve human approval, provided it cannot approve or publish its own output.
Keeping the agent from inventing things
An agent writing wiki pages will invent concepts. Left alone it produces “mTLS”, “mutual-tls”, and “client-cert-auth” as three separate pages. Call it concept mitosis. It is the biggest risk to a wiki like this.
I considered letting it run free and cleaning up later, and building the full stack: CI, a prose linter, and a vector-similarity check to catch near-duplicates before they land. The first compounds, because by the time sprawl is visible the cross-links are already wrong. The second needs the search service I had just removed.
What I built instead is a closed vocabulary. Concept slugs, tags, and link
relations live in YAML files whose changes require review under the agent instructions. It can propose
an addition; only my explicit approval records it. An aliases field collapses
synonyms, so the three names above resolve to one page rather than three.
I am clear-eyed about the limit. A script can catch a slug that is not on the list. It cannot see that a proposed concept overlaps one I already have. That check is judgment, which means my approval step is doing real work and I should not rubber-stamp it.
What the LLM is not allowed to do
Anything with exactly one right answer is a script: the catalog, the backlink map, schema validation, the vocabulary checks, the secret scan. An LLM asked to generate an index is slower, costs more, and can hallucinate an entry or silently drop a page. An index that might be wrong is not an index. A script that is wrong has a bug I fix once; an LLM that is wrong is wrong unpredictably, on every run.
The principle used here is don’t make an LLM do a linter’s job, and its corollary, don’t make an LLM do a for-loop’s job. The agent is reserved for the parts that genuinely need reading or writing prose.
Catching the agent where it writes
Ingest is where the LLM writes most. One messy source can produce or edit a dozen pages. So every run happens on its own branch, commits there, and stops. It never merges and it never pushes.
A pre-flight guard refuses to start on a dirty tree or off a non-main branch. That prevents a subtle failure I would otherwise hit: ingest topic A, forget to merge, ingest topic B, and now B is built on A’s unreviewed work with the two diffs entangled. The guard never auto-fixes. It will not stash or force-switch, and it hands the problem back to me.
I chose one branch per run, rather than one per source file or one long-lived branch. Per-run matches the unit I actually review in one sitting. Per-file is too fine, since one source legitimately updates several pages together. A long-lived branch accumulates unrelated work and loses the cheap throw-away, which was the whole point.
What crosses into public
This was the crux of the design. Publishing a project means assembling its full reasoning, and that exists in two places: the messy originals, and the compiled wiki pages. Reading the originals would give the publish step richer material.
I made it read only the compiled wiki. Three reasons, and the third decided it. The wiki provides a smaller, structured body of evidence to review. Unverified claims must still be resolved or excluded before publication. The wiki is already deduplicated and cross-linked, so the publish step does not have to redo a merge that a purpose-built step already did. Raw transcripts are particularly sensitive, so the publishing workflow excludes them. The helper restricts wiki paths, but this does not isolate an agent that still has unrestricted workspace access. Private information can also enter a wiki page during ingestion. Input selection, final-output scanning and disclosure review are all necessary.
The consequence I accepted: a published page can be no better than the wiki page behind it. If a decision record is thin, this page is thin, and the fix is always to improve the wiki, never to let the publish step improvise. That is the right tension. It makes “keep the wiki good” the single thing I have to maintain, and that is also what makes the wiki useful to me.
Since private source links cannot survive the crossing, the public page explains the reasoning in self-contained prose. Private input manifests use full-file hashes to detect changed sources. A hash is not evidence of truth, authorship, or human approval; public implementation references and test results are more useful to a reader.
Catching leaks anyway
My first design had a homegrown regex secret scanner. That was the wrong call. A handful of hand-written patterns miss cloud provider key formats, JWTs, SSH keys, and high-entropy strings, while still generating false positives, and keeping pace with new credential formats by hand is not realistic. On a security portfolio specifically, naming a maintained tool is also a more credible claim than “I wrote some regexes”.
I chose Gitleaks over TruffleHog. TruffleHog goes deeper: it verifies whether
a detected credential is still live by calling the provider’s API. Verification can be disabled with --no-verification, so provider calls are not an unavoidable property of TruffleHog. Gitleaks fits this pipeline’s straightforward offline scan and integration needs. See the TruffleHog documentation. Gitleaks is fast, MIT-licensed, and
a single binary with no dependencies.
There are separate configurations for public disclosure and private credential protection. The private pre-commit hook is an early check; CI provides a backstop. Hooks can be bypassed, so they are not a guarantee about all repository history. The hook matters most for raw chat logs, which are the likeliest place a forgotten token hides. A finding is a hard stop, not a warning.
How it works
Six operations, each a skill. ingest compiles a source into cited pages.
ask answers questions from the wiki with citations. lint audits for
contradictions, orphans, and stale pages. index regenerates the catalog and
backlinks. publish promotes a project here. vocab is the only writer of the
vocabulary files.
Three page types. A concept is a reusable explainer, a project is a thing built, and a decision is a choice with its alternatives. Each has required frontmatter and required sections defined by a JSON Schema, and carries a schema version so old pages migrate lazily instead of breaking.
Three folders, three jobs. Structure, vocabulary, and navigation stay separate. Schemas define shape, the taxonomy defines allowed words, and the indexes are generated and never hand-edited.
Ingest, in order. Resolve a topic, the join key tying several sources to one body of work. Create the branch. Check whether pages for that topic already exist, and enrich them rather than spawning parallels. Read the source for durable knowledge rather than transcribing it. Write pages with claim-level citations, propose typed links for confirmation, validate, regenerate the indexes, commit, and stop.
Publish, in order. Resolve one project slug. Gather its cluster by following wikilinks exactly one hop, never recursively, or it drags in half the wiki. Secret-scan the gathered pages, prepare a private draft, strip private source links, and explain the supported reasoning. Check input hashes and scan the final draft after rewriting. Review the exact output before an authorized public commit or PR. The deployment settings determine which push deploys.
Concretely: this page began as one reference to one project page, and pulled in eleven linked pages of reasoning automatically.
What I’d do differently
The scope oscillated badly before it settled. The design ran from an over-engineered architecture, down to “just Markdown files and a static site generator, no pipeline at all”, and back up to what exists now. The middle version was a fair correction to real overengineering on my part. Starting from “what is the smallest thing that produces a good page” would have reached the same destination faster and with less thrash.
The compile pipeline is still a plan. The resumable state machine has not been built. The reason for deferring it holds up, but it does mean what runs today is the minimum viable version.
The page types shipped narrower than designed. I specified five and built three. For a security portfolio the missing one that matters is the threat model, since STRIDE-per-element tables were supposed to be a headline artifact.
Opaque provenance hashes were not useful public evidence. They have been removed from this narrative. Private manifests now serve the narrower purpose of detecting changed inputs.
The unresolved tension is disclosure risk. Persisting raw transcripts is what makes the reasoning trail possible, and transcripts are also the likeliest place a forgotten secret lives. Review and scanning reduce that risk, but the private repo is genuinely sensitive and I treat it that way.
Duplicate detection has no automated half. The current implementation relies on canonical aliases and review for semantic overlap. Retrieval and duplication should be evaluated with real usage before selecting additional tooling.
Drafts here are AI-assisted; the decisions, the review, and the technical claims are mine. Schema checks and secret scanning support review. They do not establish factual accuracy or prove that a control was tested.