This is the panel behind everything else on this site. Laravel, MySQL, and a set of content modules: articles, projects, their categories, the about page with its skills, experience, services and testimonials, site settings, SEO metadata, and switches to turn individual sections of the public site on and off.
Since August 2026 it is also an MCP server, which means an AI agent can authenticate to it and write here. This project page was created that way — drafted from the repositories, then published through the panel's own tool interface rather than through its forms. That is the single most useful thing I have built into it, and it is the reason the rest of this page exists.
Where this panel actually came from
I have to correct the previous version of this page, which said I built the admin panel. The repository's first commit, on 22 February 2026, is 4,043 files and 639,176 insertions, vendor directory included. Nobody types that. It is an import of an existing Laravel CMS, and the eighteen locale directories still in the tree — Arabic, Persian, Estonian, Greek, Romanian, Turkish, both Chinese variants, and others I do not speak — are the fingerprints of a product built for somebody else's audience.
What I did was adapt it, hard. Two pull requests in February tried to tear its front end out in one move, 423 and 425 files, around eighty-five thousand deletions each. Both were abandoned within minutes of each other. What replaced that approach was to leave the panel's back end alone and build a separate SvelteKit application against the same database — which is why this site has two projects rather than one.
There is no note in the repository recording where the base came from or under what terms, so I am not going to name it or characterise the licence. The honest statement is: the CMS was inherited, the adaptation is mine, and the pages that follow are the parts I wrote.
Then three weeks of fighting Docker
Between 4 and 9 March there are seventeen merged pull requests, and they are all the same pull request: file permissions, the uploads directory, storage symlinks, public path handling, composer installation order, a conditional package lock, environment variables in the entrypoint, and an override file for local development. Several merged within a minute of opening.
That is what containerising an application you did not write actually looks like. It is unglamorous, it is most of the work, and it never appears in a project write-up because there is nothing to show. I am including it because the sequence is honest about where the time went, and because everything interesting below only works since it runs the same way locally and in production.
The MCP server
Six tool groups — articles, projects, categories, the about page, site pages, and LinkedIn — exposed over JSON-RPC at a single endpoint, with a separate upload endpoint because a tool call cannot carry a binary. An agent lists what exists, reads a record in full, writes it back, and can backdate it.
The authentication is the part I am satisfied with. A token is a prefix plus forty-eight random characters, shown once at mint time and never again. Only its SHA-256 hash is stored, the hash is excluded from serialisation so it cannot leak through a model or a log, every request records last-used, and revocation is a timestamp that findActive checks on every call. Tokens do not expire on their own, which is a deliberate gap rather than an oversight: revocation is manual, so an unused token stays valid until I remember it. That is the one thing here I would change if this held anybody's data but mine.
The LinkedIn tools deserve a sentence of caution rather than a feature bullet. They publish to a platform I do not control, and publishing is not reversible in any meaningful sense — the post can be deleted, but not un-seen. A capability that lets an agent write to the public internet is a different class of tool from one that edits a row in my database, and it should be treated as such by whoever holds the token.
Embeddings, and the queue I built and then ignored
Every content row is embedded so the terminal on the public site can search it semantically. Text is chunked at 500 characters with 50 characters of overlap, each chunk is sent to an embedding endpoint, and the vectors are stored against the table name and row id.
Here is the problem, and it is the clearest thing on this page. embedRow is called inline, inside the controller, on every create and every update — articles, projects, services, skills, testimonials, experience. So saving a long article is a few dozen to a few hundred sequential HTTP calls to a third-party API inside a single web request. A sixty-thousand-character article is roughly a hundred and thirty of them. Saves take minutes. I have sat and waited for them.
And a background worker already exists. It runs under supervisord, embeds one pending row per tick, sleeps a second between rows and five when the queue is empty, and prunes every five minutes. It was added on 10 April 2026. The write path has never used it. The infrastructure to do this correctly has been running alongside the code that does it incorrectly for four months, and the fix is to delete two lines from each controller and let the worker notice.
That is a genuinely instructive kind of mistake: not a thing I did not know how to do, a thing I built and then did not connect. It is the second instance of exactly that shape across my projects this year.
The deletion problem, solved twice on purpose
In April I hit a bug worth keeping: an upsert cannot represent a deletion. Re-embedding on save keeps vectors current for content that exists, and says nothing about content that has stopped existing, so deleted articles stayed searchable.
It is now handled from both ends. Each controller calls an explicit delete alongside the row delete, so the normal path is immediate. And the worker periodically runs a join against the parent table and removes any embedding whose row is gone, which catches anything that vanished by a route the controllers do not own — a manual query, a cascade, a migration. Belt and braces, and the braces are there because I have already been caught once by assuming the belt covered everything.
Provider-agnostic by construction
The AI configuration is three text fields, twice: a URL, a model name and a key for chat, and the same three for embeddings. On top of that sit separate prompts per surface — one for the terminal, one for article generation, one for project generation — and separate reasoning toggles for the terminal and for content.
It looks under-designed, and it is the opposite. In one night in March 2026 I tried several providers against it — NVIDIA's endpoint, Qwen, Gemini and others — and the only abstraction that survived contact with all of them was URL, model, key. Every provider-specific convenience I could have built would have been a guess about which provider I would still be using a month later. Three text fields means swapping the entire language model behind this site is a form submission, and it means the chat model and the embedding model can come from different vendors, which they do.
Where I would push back if this were a review
The inline embedding above is the headline, and the unused worker makes it worse rather than better, because it proves I knew.
The panel carries a large amount of code I did not write and do not exercise: eighteen locales for an interface only I log into, and modules from the original product that my content model does not use. Inherited code you never delete is inherited risk you never audit. August's cleanup made a start — a pull request that dropped an unused author column and removed around 1,700 lines across eighty-four files — and it is a start, not a resolution.
Embedding calls have no retry and no dead-letter path in the inline case; a provider hiccup during a save leaves a row with partial vectors, and the periodic worker is what quietly repairs it. That works, and it means the recovery mechanism is a side effect rather than a design.
And the panel is a single administrative surface with full write access to everything public, now including a machine interface and the ability to post off-platform. It is protected by exactly one person's discipline. That is a fine risk model for a portfolio and a completely inadequate one for anything else, and the distinction is worth stating rather than leaving for a reader to assume in my favour.
Stack
Framework Laravel, PHP 8
Data MySQL; embeddings stored per table, row and chunk
Interfaces Blade admin UI, JSON-RPC MCP endpoint, upload endpoint
Auth session login for humans, hashed bearer tokens for agents
Background supervisord worker for embedding and orphan pruning
AI any OpenAI-compatible chat and embedding endpoints
Hosting Docker on a single VPS, see the DISNUT project
What it demonstrates
Taking an inherited codebase and making it yours: containerised, deployed, extended, and progressively cut down. A machine-facing API with credential handling I would defend line by line. A retrieval pipeline with an ingestion path, a deletion path and a repair path. A configuration design chosen by trying five vendors in a night rather than by reading about abstraction.
And a willingness to publish the sentence "the background worker exists and my controllers ignore it" on the page advertising the work. The panel's real output is the honesty of the pages it produces, including this one.