Skip to content
Back to Blog

How I Built an AI Agent for my Portfolio (Yabasha.dev) using Laravel & Next.js

Bashar AyyashDecember 19, 2025Updated September 21, 20269 min read1,783 words
How I Built an AI Agent for my Portfolio (Yabasha.dev) using Laravel & Next.js
TL;DR

yabasha.dev generates its own SEO metadata from one queued job. PostObserver checks seven fields after every save and dispatches GeneratePostSeoJob when any is empty. The job calls one active OpenRouter model through PostAiService, clamps the summary to 60 words, and falls back to a heuristic that needs no model at all. There is no multi-step agent.

9 min read · 1,783 words

This post is about this site, so every claim in it is checkable. I checked them all, and most of the original ones failed.

I published a version of this in early 2026 describing a five-step agent: fetch the draft, generate metadata, run an SEO pass on a second model, validate, publish. It had a benchmark table pricing five models per task, a cost ledger, and a Filament widget showing thirty days of spend.

Almost none of that is in the repository. There is no FetchDraftJob, no GenerateMetadataJob, no AiCall model, no agent_runs table and no spend widget. I described the system I meant to build.

The agent I wrote about had five steps, per-task model routing and a cost ledger. The one that survived has one step, one model and a heuristic. The simpler one is the one that still runs.

Here is the real thing.

What actually triggers the agent?

Saving a post. There is no review status, no queue button and no cron. An Eloquent observer runs after every save, checks seven columns, and dispatches one job if any of them is empty.

Column checkedPurpose
meta_descriptionThe search snippet
ai_summary_shortThe TL;DR block, capped at 60 words
ai_summary_longThe longer abstract
ai_topicsTopic labels
meta_keywordsKeyword list
primary_keywordThe single target term
secondary_keywordsSupporting terms

If every one of those is populated, nothing happens. That single condition is the whole scheduler, and it means the agent is idempotent by accident rather than by design: refill the columns and it stops firing.

The trigger is not "the author asked for metadata". It is "a column is empty". That distinction is why clearing a column is how you ask for a rewrite.

What does the job actually do?

One model call. GeneratePostSeoJob hands the title and body to PostAiService::analyzeContent(), which builds one prompt, sends one chat request through OpenRouter, and parses one JSON response covering all the fields above.

StageClassWhat it owns
TriggerPostObserver::saved()The seven-column check
QueueGeneratePostSeoJobThe timeout, and which fields may be overwritten
Prompt and parsingPostAiServiceBuilding the prompt, decoding JSON, clamping output
TransportOpenRouterServiceModel selection, HTTP, failover
CatalogueAiModelWhich model is active

Five classes, one call. The earlier version of this post split that into five queued steps because five steps sound like an agent. They are not free: each boundary is a place the run can die halfway with some columns written and some not.

Why is there only one active model?

Because choosing per task means maintaining a routing table, and the routing table was the first thing I deleted. AiModel::active()->first() returns one row, and that row's request_id is the model for every call on the site.

The catalogue itself is synced from OpenRouter and stores more than the id.

FieldWhat it holds
request_idThe model string sent to OpenRouter
context_lengthToken window
pricing_prompt, pricing_completionThe vendor's list price
estimated_speed, actual_speedExpected against measured throughput
is_activeExactly one row carries this

Those price columns are the vendor's published rates, not what I spent. Nothing in this repo records the cost of a call. A post that claimed an average cost per post was quoting a number no table holds.

If you want the version with real cost attribution, it is the client system in cutting LLM API costs 60%, where every call was traced before anything was optimised.

What happens when the active model disappears?

OpenRouter retires models, and the failover for that is the one piece of real agent machinery here. It is one level deep and it fires on unavailability, not on a retry count.

OpenRouterService::chat() sends the request with the active model. If the response fails and the error means the model is gone, it deactivates that row, notifies the admin, and resends with the environment fallback model. If that also fails, it logs and returns null.

The failover is not a retry ladder across three vendors. It is one swap, triggered by the model being retired rather than by the request being slow.

That is smaller than what I described before, and it covers the failure that has actually happened to me.

What runs when the model returns nothing usable?

A heuristic that calls no model at all. Three different failures collapse into the same path, and none of them reach the caller as an exception.

FailureWhat analyzeContent() does
Null response, including a missing API keyLogs a warning, returns fallbackAnalysis()
Response does not decode as JSONLogs the decode error, returns fallbackAnalysis()
Anything throws while processingLogs the exception, returns fallbackAnalysis()

fallbackAnalysis() reads the post and produces a summary, an excerpt and a description by truncation and sentence splitting. It is unglamorous and it always answers.

There is a second fallback underneath that one. If the model answers but omits meta_description, the job calls Post::deriveDescription(), which takes the excerpt, or the body if there is no excerpt. That is why a good excerpt is the best insurance in the whole pipeline.

Why is the model's output clamped before it is saved?

Because the model overshoots the word budget often enough to lose the sentence worth quoting. ai_summary_short is capped at 60 words. The frontend truncates on sentence boundaries, so an over-long summary loses its final clause, and the final clause is usually the one carrying the outcome.

So the same clamp the heuristic uses is applied to the model's answer too. It is a two-line fix for a failure that is otherwise invisible: the field is populated, the type is right, and the most citable sentence is missing.

Schema validation tells you the field arrived. It does not tell you the useful half of the sentence was cut off. That check has to be written by hand.

How long is the job allowed to take?

Longer than the call it makes, which sounds obvious and was not. A single OpenRouter request is allowed 120 seconds. Horizon gives a worker 60.

LayerLimit
Horizon worker timeout60 seconds
Horizon retries3
Single OpenRouter call120 seconds
GeneratePostSeoJob timeout150 seconds

For a while the job declared no timeout of its own, so it inherited 60 and any post long enough to take the model past a minute was killed mid-request. It wrote nothing and produced no useful error. I wrote that failure up properly in building production AI agents that actually work; the short version is that the job now declares 150 and the comment above it says why.

The retries are Horizon's, not the job's. The published version of this post gave the job $tries = 3 and a three-step backoff array. It has neither.

How does the published page find out?

Through a second job, not through this one. PostObserver also dispatches RevalidateFrontendJob, which posts a cache tag to the Next.js side, and the route calls revalidateTag.

There is a catch worth knowing if you build this. The SEO job writes quietly, without firing the observer again, so regenerating metadata purges nothing. The page waits out its own timer instead. A curl immediately after a run still shows the old value, and I have talked myself into believing a column was dropped on exactly that evidence.

The full protocol, including the acknowledged version I would build if staleness cost money, is in the cache handshake.

What does the admin side actually look like?

Filament 4, with five widgets, none of which are about AI spend.

WidgetWhat it shows
ContentOverviewStatsCounts across content types
PublishingPipelineStatsWhat is drafted, scheduled and published
LatestPostsTableRecent posts
NewsletterStatsSubscribers and issues
UpcomingSpeaksTableScheduled talks

The real controls are artisan commands rather than admin pages: posts:regenerate-summary for one post's TL;DR, posts:apply-revision to write a whole post from a markdown file, and posts:publish-scheduled, which the scheduler runs every minute.

Which versions is this running on?

Read from the lockfiles on 2026-09-21, because the published version of this post was wrong about two of them.

PieceVersion
Laravel12.69, PHP 8.4
Filament4.12, not 3
Next.js16.3.4, not 15
QueueHorizon on Redis
Model accessOpenRouter, one active model

What is still missing?

Four things, and I am listing them because the previous version of this post claimed three of them were already built.

GapWhy it is still open
No per-call cost recordThe catalogue holds list prices, not spend. I would need one table and one write per call
No evaluation setNothing checks whether a new model writes better metadata than the old one. Model swaps are judged by reading a few posts
No retry on a bad summaryA too-short or off-topic summary is fixed by clearing the column and saving again, by hand
No trace across the two appsCorrelating a publish to a purge is still grep. I removed error tracking and log JSON instead

The architecture around all of this is described in what yabasha.dev actually runs, including the parts I would not ship to a client.

What would I tell someone starting this?

Build the trigger and one call. Nothing else, at first.

The instinct is to design the pipeline, because a pipeline is the interesting part. But a five-step pipeline has four places to die halfway, and on a personal site there is no traffic to justify that risk. One observer, one job, one model and a plain-code fallback covers the whole job of labelling your own writing.

The hard part was never the agent. It is still thinking clearly enough to draft something worth publishing. Everything downstream of that is plumbing, and plumbing should be boring.

The second mistake is never automating anything. That one I did avoid.

Read from the tree on 2026-09-21

  • apps/web/package.json, apps/backend/composer.json for every version above
  • PostObserver, GeneratePostSeoJob, PostAiService, OpenRouterService, AiModel
  • config/horizon.php for the worker timeout and retry count
  • routes/console.php for the scheduled commands
  • app/Filament/Widgets/ for the widget list

The repository is private, so none of this is a link you can follow. The earlier version of this post said the pipeline was open source and invited readers to go and read it. That was wrong, and it is the correction I most wanted to make.

Questions: @bayyash

Bashar Ayyash
AUTHOR

Bashar Ayyash (Yabasha)

AI Systems Architect for regulated industries — evals, harness design, AI security.

Bashar Ayyash is an AI engineer and dev lead in Amman, Jordan. 20 years shipping software, 4 years inside a regulated bank building production RAG and agent systems with evals, guardrails and monitoring — in Arabic and English. He writes at yabasha.dev and builds open-source tooling for AI-assisted development.

Newsletter

Practical AI + full-stack insights for MENA builders. No spam.

Read more on the blog

Browse the latest articles or explore the full archive.