ttushar.

Full-stack developer · 2026

Jurnii

An AI platform that drives real browser agents through customer journeys to score digital experience, plus a competitive intelligence product tracking promotions across the betting market. I owned the journey execution engine and the Customer 360 module.

TypeScriptLangGraphNext.jsHasuraPostgreSQL

What Jurnii does

Customer experience consultants audit websites by hand. Someone opens the site, tries to sign up, tries to deposit, tries to find the help page, writes down what went wrong, and turns it into a slide deck. It takes days, it costs a lot, and two consultants auditing the same site will disagree about half of it.

Jurnii automates that audit. It sends an AI agent through a real browser, has it attempt the journeys a customer would attempt, and scores what happened against a heuristics database. The output is a report with evidence attached: screenshots of the actual failure, not a consultant's recollection of it.

I worked on two parts of this. The interaction graph is the engine that executes journeys and decides whether an execution can be trusted. Customer 360 is a separate product built on the same infrastructure, aimed at the betting and gaming market.

Part one: the interaction graph

The problem with browser agents

Give an LLM a browser and a task like "sign up for an account" and it will do something. Whether it did the right thing is a completely separate question, and the agent is the worst possible judge of it.

Agents stop early and report success. They get stuck in a cookie banner for six steps and then run out of budget. They click a lookalike button and end up on the wrong page while narrating confident progress. They hit a Cloudflare challenge and describe the challenge page as though it were the signup form.

None of that is unusual. What makes it expensive is that an assessment built on a bad execution looks exactly like an assessment built on a good one. The report still renders. The scores still populate. A human only finds out when a client asks why the screenshot shows a cookie dialog.

So the real engineering problem is not "can an agent use a browser". It is "how do you know the run was any good, without a person watching every run".

The graph

The engine is a LangGraph state machine in TypeScript. The full path runs: perform the automation, evaluate it with a QA bot, work out which URL to measure performance against, generate metadata, capture the journey flow, validate the screenshots, evaluate heuristics, then generate recommendations.

The interesting edge is the one out of the QA bot, because it can go backwards.

After the browser agent finishes, a separate LLM call receives the journey's name, its description, the steps the agent was supposed to take, and the steps it actually took along with the captured media. It returns four things: a confidence level from 0.0 to 1.0, a decision of continue, retry or stop, a justification, and written feedback aimed at the next attempt.

Continue moves the run forward. Retry deletes the previous execution data from the database, then routes back to the automation node with the QA bot's feedback injected into the task prompt, so the second attempt is told what went wrong on the first. Stop halts the run and pages a human through the ops notification channel.

Two details matter more than they look.

The first is that retry is capped at two attempts and the cap is real. When it runs out, the run does not quietly proceed with bad data. It escalates to a person. Anything else and you get an assessment nobody can defend.

The second is a confidence override. If the QA bot says retry but its own confidence is already high, the graph continues anyway. That looks like ignoring the evaluator, and it is, deliberately. Without it the bot would send perfectly good runs around the loop again over cosmetic complaints, at real cost in browser minutes and tokens.

Screenshots, which are harder than they sound

Evidence is the product. If the screenshots are wrong the report is worthless, so capture logic sits directly in the agent's step callback.

Not every step is worth a screenshot. Actions that only extract text or think out loud change nothing visually, so those are skipped. The first page or two get skipped for most journeys, since a signup journey should not open with a picture of the homepage. And when two steps fail back to back, that fires a capture specifically because the failure is the thing worth seeing.

Everything lands in S3-compatible storage as JPEG at 85 quality. That number was a real trade. Vision model calls are priced on image size, and full-quality captures were expensive enough to matter across thousands of journeys, but push compression too far and text in the screenshot stops being legible to the model reading it.

Modes, and why there are five

The same graph runs in five configurations: full execution, automation only, journey evaluation, skip browser, and screenshot verification.

This came out of debugging. A full run is slow and costs money, and when the heuristics evaluation is producing nonsense you do not want to drive a browser through a signup flow to find out. Journey evaluation reuses stored execution data and runs only the reasoning nodes. Automation only does the reverse. It made the feedback loop minutes instead of tens of minutes, and it is the change I would make first if I were starting over.

Recurring assessments

Sites change. A brand that fixed its checkout last month should not be scored as though nothing happened, so recurring runs branch into a different path that pulls historical context, compares the current flow summary against previous ones, and generates recommendations against the delta rather than the raw state.

Getting this to work required a canonical step identity. Screenshots from March and screenshots from July only compare if the system agrees that "step three" means the same thing in both, which is a genuinely awkward problem when the page itself has been redesigned in between.

Part two: Customer 360

Customer 360 is a different product on the same platform. It tracks what betting and gaming operators are offering, continuously, across the market.

The customers are operators who need to know what their competitors are promoting right now. A free bet offer, a deposit bonus, an odds boost on a specific football match. That information exists publicly on competitor websites and it changes daily, so people were doing it by hand and getting a stale spreadsheet.

The scope is 52 database tables, 36 repository modules on the pipeline side, and around 260 UI components. Roughly 600 of my commits on this repo are scoped to it.

The pipeline

Each brand has discovery URLs, and each URL is flagged for what it should yield: promotions, hero banners, odds boosts, or some combination. A scheduler works through them.

Scraped output goes through a diff engine, which is the part I would point at if someone asked what the module actually is. Its job is deciding whether a promotion card seen today is the same promotion seen yesterday. Get that wrong in one direction and every daily scrape invents a fresh set of duplicates. Get it wrong in the other and a genuinely new offer disappears into an existing record. The engine deduplicates within a scrape, matches against what is already stored, and maintains a first-seen and last-seen timeline per promotion.

Promotions then get their details extracted per vertical, because a sports offer and a bingo offer have almost nothing in common structurally. Sports promotions carry eligible markets and sports. Casino carries eligible games, providers and activation methods. Bingo carries eligible rooms. Poker has its own shape again. Each gets its own detail table rather than a shared bag of nullable columns.

Around that sits the machinery you only discover you need once it is live: currency conversion so a 50 euro offer and a 40 pound offer are comparable, supplier funding attribution to work out who is actually paying for a promotion, a fixtures knowledge base so an odds boost can be tied to the real match it refers to, and scrape tracking with error records and a human review queue.

What the customer sees

Seven areas: an overview, promotion data broken down by brand, a live feed, a boost feed, a compare view, market richness trends, and AI insights.

Filtering turned out to be the hard part of the UI. A user picks a group, then a vertical, then a sub-vertical, then a sport, and every chart, table and feed on the page has to respect all of it. I built that cascade behind a feature flag and shipped it incrementally, wiring one section at a time to the global sport filter: the overview cards, then the category classification chart, then the live feed, then the compare metrics. Doing it in one pass would have been a diff nobody could review.

The data quality work

This is the part I did not expect to spend so long on, and the part I would now say mattered most.

An audit surfaced a batch of defects in the promotion data. Most of them were quiet, in the sense that nothing crashed and no alert fired. They just made the numbers subtly wrong.

Odds fields were storing scraped label text alongside the price, so a boost that went from 9/4 to 14/5 was persisting the words "Was" and "Now" into the string columns. The decimal columns had been right the whole time, which is exactly why nobody caught it.

Customer type had collided on case. Some rows said "Existing" and some said "existing", which quietly split every group-by in the product. Tracing it back, a since-removed write path had been persisting enum keys instead of values inside a bounded window of about ten weeks. I added case-insensitive coercion at the write boundary so it could not recur, then folded the 24 legacy rows to canonical casing and verified nothing non-canonical remained.

Brand names were being used as a join key between export sheets while their display casing was inconsistent, so the joins silently dropped rows. That got a proper region-qualified slug.

My favourite was the boost sport misclassification. An audit had framed it as a taxonomy gap, meaning the sport categories were missing. They were not. Baseball, ice hockey, MMA, rugby league and motorsport all existed as categories, but none of them were enabled for any group, and the classifier's schema was generated from the enabled set. So the model was structurally incapable of returning them. UFC events were being filed as boxing, Super League as rugby union, Formula 1 as other. About 1,800 boosts over a month were affected. The fix was a one-line gating change plus a reclassification script, but only after correcting the diagnosis, which is the part that took the time.

Then there was a liveness bug worth mentioning because it is such an easy mistake. Boost exports were scoped by created date, so a boost that first appeared on Tuesday and was still live on Thursday did not show up in Thursday's export. Someone flagged missing boosts, and the capture had actually worked fine: 70 of 70 were in the database, and 40 were being filtered out by the query. Replacing the date window with a first-seen and last-seen overlap predicate fixed it.

Extraction accuracy

Hero banner extraction was measurably bad and I could prove it, which made it fixable.

Two failure modes ran in opposite directions. On some sites the extractor pulled in promotional strips that were not part of the hero at all, returning ten to thirteen items where the hero had five. On others it under-captured, because the carousel virtualised and only about two of eight slides existed in the DOM at any moment.

The fix was several things: a per-URL flag for carousel advancement, a pagination-dot fallback for the sites that needed clicking through, and a viewport screenshot fed to the model as a structure hint over a second browser connection, which fails open to HTML-only if the capture does not work.

I benchmarked it against frozen captures rather than live sites, so the before and after numbers meant something. Exact count accuracy went from 21% to 76%. Mean count error dropped from 1.38 to 0.48. Null names fell from 9.9% to 2.5%, and duplicates from 13.1% to 1.0%.

One brand is still wrong under every configuration tested. I would rather write that down than round it off.

An infrastructure problem that was not what it looked like

Production showed daily CPU spikes hitting 60 to 100% during scheduler batches, which read as a compute problem.

It was not. Without an explicit concurrency declaration, the platform proxy was defaulting to a soft limit high enough that nearly all job traffic packed onto two of three machines, with peak in-flight requests around 18 to 20 on those two while the third machine received eleven requests in an entire day. The spikes were shared-CPU throttling on two overloaded machines, not genuine demand.

Setting a request concurrency soft limit of six spread the load properly. Adding more machines, which was the obvious first instinct, would not have helped at all.

What I would do differently

The interaction graph earns its complexity, but I would build the execution modes on day one instead of month four. Almost every hour I lost to slow feedback loops was recoverable that way.

On Customer 360, I would put the data quality checks in before the dashboards rather than after. Every defect in that audit had been shipping into charts for weeks before anyone questioned a number, and by then the fix carries a backfill and a credibility problem. Validating at the write boundary is cheap. Explaining to a customer why last quarter's export was wrong is not.