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 registry is a little over 1,400 lines across six tool classes, and 772 of those lines are LinkedIn alone.
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.
What September added, and what it says about the risk
The LinkedIn group got its full shape at the end of August: nine tools, covering status, publishing, scheduling, listing what is scheduled, cancelling it, listing what was published, editing a published post, deleting one, and reacting to a post. An agent holding this token can now write to my public feed, change what it already wrote, delete it, and put a reaction on somebody else's post under my name.
The scheduling path has a worker behind it that runs under supervisord, takes up to five due posts per tick, retries a failure twice and then marks the row permanently failed with the reason stored. Attempts are incremented before the publish is attempted rather than after, so a process that dies mid-call cannot produce an infinite retry. That is the right shape for a job whose side effect is public and not undoable.
Which brings me to the caution this deserves, now stronger than when I first wrote it. Publishing is not reversible in any meaningful sense — the post can be deleted, but not un-seen — and the reaction tool means the token can attach my name to something I never read. The whole set is gated by one bearer token that never expires, held by a process that takes instructions in natural language. Every individual protection here is sound. The composition is one credential away from an agent speaking as me in public, and I built it that way on purpose because it is convenient, which is exactly the sentence that precedes most incidents of this kind.
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 still 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.
I counted the call sites before writing this revision: twenty-four, across six controllers and the content-write service that the MCP tools go through. Twelve of them are in that one service, which means every page published by an agent — including this one, as it saves — takes the slow path too. When I wrote this section in August I said the fix was to delete two lines from each controller. It is twenty-four lines now, and the number grew because I added a machine interface on top of the broken path instead of fixing the path first.
The September comparison is the uncomfortable one. I built the LinkedIn scheduler as a worker and wired it up the same week, correctly, with retries and a failure state. So it is not that I do not know how to do this. The embedding queue is five months of knowing exactly what to do and doing something else, while a second worker went in beside it and got connected on day one.
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.
Storage left the container
On 10 September uploads moved to S3-compatible object storage: 28 files, the filesystem config, the helpers, both content controllers, the MCP upload endpoint, the rich-text upload endpoint, the blade views, the entrypoint, and a one-line change on the SvelteKit side so public image paths resolve to the bucket when it is configured and fall back to the old path when it is not.
The reason is unglamorous and worth stating: uploads were living on a container's own disk, which means a redeploy was one wrong volume declaration away from taking the images with it. Nothing had gone wrong. I moved it because the failure mode was obvious and the fix was cheap while nothing was broken, which is the only time this kind of work ever actually gets done.
Tests, finally in the merge path
There are now eight unit test files and two feature tests, covering the MCP protocol layer, token handling, the tool registry, upload path safety, the safe URL fetcher, the content-write helpers and the LinkedIn planning logic. Late August fixed two things that are always the same two things: PHP extensions missing from the CI image, and a full-text migration that worked on my database and failed on a fresh one.
That second one is the useful bug. A migration that only runs against a database with history is a migration nobody has ever actually tested, and the only way to find out is a clean run — which CI does and I never do.
Where I would push back if this were a review
The inline embedding above is the headline, the unused worker makes it worse rather than better because it proves I knew, and the fact that a second worker went in correctly this month while the first stayed unused makes it worse again.
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. The LinkedIn worker, written five months later, has the retry and the dead-letter state the embedding path still lacks. I know what the correct shape looks like; I have simply never gone back.
And the panel is a single administrative surface with full write access to everything public, now including a machine interface and nine tools that post, edit, delete and react off-platform. It is protected by exactly one person's discipline and one non-expiring token. 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: embedding worker, LinkedIn scheduler, FrankenPHP
Storage S3-compatible object storage for uploads
AI any OpenAI-compatible chat and embedding endpoints
Tests PHPUnit, in the merge path
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. A publishing worker that treats an irreversible side effect with the care it deserves.
And a willingness to publish the sentence "the background worker exists and my controllers ignore it" on the page advertising the work — then come back a month later and publish the number of call sites, which went up. The panel's real output is the honesty of the pages it produces, including this one, and honesty is only worth anything if the next revision is allowed to say the problem got bigger.