Data that survives contact with reality.
Computer engineer, M.S. in Applied AI, two years deep in the messiest data in advertising. I build the BigQuery pipelines, containerised GCP services and LLM applications behind media reporting for 10+ enterprise brands across MENA: Americana, IKEA, Amazon, Qatar Airways, Emirates Group and others.
The lab years
Four years of computer engineering at the Lebanese American University, then three internships that had nothing in common. This is where I learned to distrust a number until I understood the instrument that produced it.
It started with hardware, not spreadsheets.
I trained as a computer engineer at the Lebanese American University: circuits, embedded systems, computer vision. Not a single course on marketing attribution.
That turns out to matter. Engineering teaches you to distrust a reading until you understand the instrument producing it. Every number I've shipped since has been treated the same way: where did this come from, what could be lying, and how would I know.
The last thing I built there before graduating became a paper, and a public dataset alongside it.
Five surface materials a walking robot might cross, photographed under varying light with a Raspberry Pi camera, each image paired with a friction coefficient measured on a rig I built from a DC motor and an encoder. MobileNetV2 and InceptionV3 both classified it above 90%. Presented at the International Conference on Mechatronics and Machine Vision in Practice. Advised by Dr. Noel Maalouf.
The labelled dataset behind the paper, published openly so the result could actually be reproduced.
The undergraduate builds, and the one that got published.
Undergrad is where you get to solve a problem badly three times before you solve it well, and nobody's budget depends on it. Mine went mostly on vision and language, with detours through digital logic, distributed ledgers and tree algorithms.
Reading these back, they have one thing in common I did not plan. In every one of them the interesting work turned out to be upstream of the model. Building a dataset that did not exist. Deciding what a letter looks like in numbers. Working out which signs a glove physically cannot tell apart. The model was usually the least surprising part.
Real-time ASL to text
A sign language translator, and a hardware project before it was ever a vision one. The input was a glove: a variable resistor along each finger, so bending one changes its resistance, read by custom hardware and matched against a lookup table of bend signatures. No model, no camera, no inference. Five numbers and a table.
That works until it doesn't, and the way it fails is the interesting part. A flex sensor measures how far a finger bends and nothing about where it ends up. R, U and V all hold index and middle straight with the rest closed, so all three produce the same five resistances. The glove is not being inaccurate. The information is not in the signal.
So MediaPipe went in as a tiebreaker, not the classifier. The lookup table answers whenever a signature is unique; only when it returns more than one candidate is the camera consulted, measuring the lateral distance between index and middle fingertips normalised by hand size so it holds at any distance from the lens. Pick a letter and watch which stage decides it.
Pick a sign
Image-based material estimation PUBLISHED
A quadruped robot choosing where to put a foot needs to know whether the surface will hold before it commits weight to it. This was a convolutional network predicting material class and friction behaviour from an image alone, built for the quadruped the lab was developing.
The dataset did not exist, so most of the work was building it, and the rig is the part I would defend hardest. Measuring friction properly means measuring a force, so each surface was dragged by a DC motor with an encoder, and the coefficient came out of the relationship between current drawn and movement recorded. That is a mechatronics problem sitting underneath a computer vision problem. Five materials, several lighting conditions, every sample labelled by hand, and that is where most of the term went.
Two architectures were trained on it, MobileNetV2 and InceptionV3, and both classified material above 90%. I am deliberately not making more of that than it deserves: the dataset is small and it is ours, so a high score on it is a starting point rather than a finding.
The work was presented at M2VIP, and the dataset went out alongside the paper. That mattered more to me than the accuracy did. A result nobody else can reproduce is barely a result, and the dataset is the one part someone else can pick up and argue with.
The pipeline
Tap any stage to see what happens there.
Handwriting, described in numbers first
Sixty-two classes, 3,400 images, and a constraint that was the point of the course: we had to say what a character looks like in numbers before any model saw it. Vertical, horizontal and diagonal symmetry. Projection histograms counting dark pixels down every column and across every row. Histogram of oriented gradients. All written to a CSV, and the models trained on that table rather than the picture.
Then we trained a convolutional network, which skipped the exercise and found its own features. It reached 95% on training and 75% on validation. The twenty-point gap taught me more than the accuracy did. The hand-built features topped out at 70% and were honest about it the whole way.
Draw something in the box. Every number on the right is computed from your strokes.
Extracted from your strokes
Nearest match
The strips beside and beneath the canvas are the projection histograms: activated pixels per row and per column. They are the cheapest useful description of a shape you can compute, which is why the same two arrays turn up again in the Oculi work later on.
And a term of it was hardware
Half this degree was spent below the software line: logic design, circuits, microprocessors, and an FPGA course whose final project was a Verilog build on an Altera board.
The full build was a calculator, and almost none of it was arithmetic. A PS/2 keyboard controller decoding scan codes off a clocked serial line, a state machine tracking what had been typed and what it meant, an LCD driver holding timing the display would accept, and only then did anything add up.
Hardware teaches a lesson software lets you avoid: everything happens at once unless you make it not. No call stack to reason along, only signals settling between clock edges, and a design that works at one clock speed and fails at another is telling you about your own assumptions rather than about the board.
The decoder below is the smallest module in the project and the one I still reach for as an example. Sixteen inputs, sixteen outputs, no clock, no state, nothing to go wrong at three in the morning. Most of what I now like about a good function I first liked about a case statement.
segment7.v
Putting a national vote on a chain
A software engineering course run the way the course wanted: Jira board, sprints, backlog grooming, use cases before a line of code. The team built a full e-government platform, not a demo. Register and sign in, request an official document, pay the fee, book an appointment, find the nearest branch. React on the front, Node and Express behind it, a REST API over a real database.
My piece was the voting, and the reason to build it that way is corruption. The problem with an election is rarely that the count is hard, it is that nobody outside the room holding the ledger can check it. The property you need is not that the tally is correct now, but that nobody can quietly make it incorrect later and have it still look untouched. A database gives you the first and not the second: whoever administers it can rewrite history and leave no trace.
So the ballot went onto a smart contract. Candidate list and running counts live on chain, the browser talks to it through the voter's own wallet, and a vote is a transaction that must be mined before it counts. Every vote is public, permanent and independently checkable, and a voter can verify their own ballot landed without trusting the people running the election.
The rule doing the real work is one line: an address that has already voted cannot vote again. Not enforced by the interface, which anyone can bypass with the developer console, but by the contract, which nobody can. Cast a ballot below, then try a second. Then tamper with an earlier block and watch the chain stop agreeing with itself.
Chain
Each block stores the vote, the hash of the block before it, and its own hash over both. Change an old vote and every hash after it stops matching, which is the whole reason the tally is worth anything.
Three internships, and the one that changed what I wanted to do.
Between graduating and Dubai I worked in three places with almost nothing in common: a sales startup in Washington, a semiconductor company in Beirut, and a shipping line. All three pointed at the two things I have been doing ever since, which are vision and prediction.
Two threads at once. A whitespace model over the sales pipeline, forecasting how much budget an account had left to spend. And a Slack chatbot built on LLMs, where the model was the easy part: the work was context and memory, deciding what the bot should still know three messages later and what it was better off forgetting. The first thing I shipped with a language model inside it.
Extracted regions of interest from an event-driven vision sensor using nothing but projection histograms, then built the class that turned its output into a labelled training dataset.
Built a late-shipment prediction model at 90% accuracy, and applied GANs to tabular data for synthetic augmentation in logistics forecasting.
Where the rest of this story happens.
Twelve weeks at a sensor company
Oculi builds a sensing unit that does its thinking at the pixel, before anything becomes an image. The point is bandwidth: instead of shipping frames off the sensor for something else to analyse, it ships actionable signals, which in my mode meant two arrays per frame. Activated pixels per row, activated pixels per column.
My job was to put a box around a moving person using only those two arrays. No image. The first version was as direct as it sounds: threshold each array, take the first and last index above it, and those four numbers are your bounding box.
It broke twice over. A noisy background dragged the box out to the frame edges, and a subject close to the camera fragmented into several disjoint boxes. The fixes were merging adjacent boxes, padding, and smoothing the signals before thresholding: a linear kernel first, then Gaussian, on the reasoning that a person's activation profile is roughly a bell centred on them. It held.
It never fully worked. When a body moved less than its arms, the algorithm tracked the arms and lost the torso, and I handed it over in that state.
Halfway through, the work was set aside for something more valuable: the dataset underneath it. I annotated 3,960 frames in CVAT, a box around the subject's hands in each, exported to YOLO, and wrote the class turning any video into per-frame records: frame number, dimensions, projection histograms, intensity sums, labels where they existed. Then a low-light dataset rebuilt from stills into video, and Doxygen through the codebase so the next person could pick it up.
Before any of that: the sensor does not have one output, it has nine, each throwing away something different. Every mode carries a measured number, so choosing a mode is choosing what you are willing not to know.
The brief I arrived with was a smart fitness tracker: read posture and movement and tell someone whether they were doing an exercise properly, using the sensor together with pose landmarks. I evaluated the two obvious libraries and neither survived contact. MediaPipe was accurate and power-hungry, because it resizes every frame, which is exactly the cost a sensor like this exists to avoid. OpenPose did not detect people reliably in this footage at all. That result is what pushed the work back toward deriving the region of interest from the actionable signals directly, and it is the most useful negative result I got there.
Two arrays counting activated pixels per row and per column. The same pair of features I had hand-built for a character recogniser the year before, arriving this time out of a chip instead of out of a CSV.
The day job
Since August 2024 I've been a data analyst at Choreograph, WPP's data and AI company, in Dubai. Here's what that actually means.
Rebuilding CTGAN to find out how it works
At the shipping line the headline task was a late-shipment prediction model; the more useful one was why we needed synthetic data at all. Real logistics data is unbalanced where it matters: the delays you most want to predict are the rarest rows you have.
The standard answer is CTGAN, a GAN built for tabular data. We could have imported it. Instead we reimplemented it in PyTorch, a decision I would make again, because a library you have rebuilt is a library you can debug.
Rebuilding it teaches why it is not a GAN pointed at a spreadsheet. Continuous columns in tabular data are usually multimodal, so normalising a whole column to one scale asks the generator to model a shape it cannot represent, and it settles on the average of the modes: a value the real data never takes. And categorical columns are imbalanced, so a class holding three percent of the rows is close to invisible to a generator sampling by frequency.
CTGAN answers the first with mode-specific normalisation and the second with conditional training-by-sampling. Switch between them below.
Plain GAN
Rare category survival
Turning a dozen scattered sources into one honest picture.
Brands advertise across a lot of platforms. Each reports in its own format, on its own schedule, counting things its own way. Somebody has to turn that into a single view a marketing team can decide from.
That's the job: the pipelines that collect the data, the models that make it comparable across platforms, the dashboards people read, and the automation that keeps it running without me touching it.
The less obvious part is the joining. Advertising data alone tells you what an ad did. Put it beside app installs, brand tracking and sales and you can ask whether any of it mattered.
Platforms
Brands
Built with
The creative pipeline, and why it had to exist
Every platform tells you how an ad performed: impressions, clicks, completion, all through an aggregator API. None tell you why, because the thing that actually varies is the creative, and the creative is not in the numbers.
The aggregator does hand you a URL per creative, so the obvious move is to fetch the asset and join what you see to how it did. Except the URLs expire. They are CDN links with a lifetime, and once one goes stale the asset behind it is gone. The performance row survives forever. The thing it describes does not.
That asymmetry is the problem: creative analysis has a shelf life of weeks, and "what worked for this brand last year" is unanswerable, not because the data is missing but because the pictures are.
So the first thing I built is deliberately unglamorous: a job that takes every creative URL, downloads the asset while the link still resolves, and writes it to a Cloud Storage bucket keyed to the creative id. Images and video. Nothing clever, just durable.
With the assets held, each goes through metadata extraction: computer vision for the measurable properties, generative models for the descriptive ones a classifier would struggle to name. Faces and how many. Text and how much of the frame it takes. Dominant colour and whether the palette runs warm. Logo presence and position. For video, duration, cut rate, motion. Joined to the performance rows, the question stops being which ad won and becomes which properties of a creative are associated with winning.
Below is the shape of it, with correlations computed live from the set on screen. Then turn off the bucket and watch your evidence disappear.
Extracted metadata
Associated with performance
Three applications, shipped
Three internal tools, all React and TypeScript over a Postgres backend, each built to a deadline rather than to a specification. The interesting part of every one of them was never the interface. It was the set of decisions that determine whether a thing is safe to put real data in, and whether the numbers it shows mean anything, and that is what each of these write-ups is about.
Scoring a billboard for a specific audience
An out-of-home mapper: every poster, screen, mupi and wall wrap a brand runs, placed by latitude and longitude, tagged with format, media owner, price, campaign dates and year. That last field is what makes it more than inventory, because with a year on every row you can put 2025 beside 2026, or your sites beside a competitor's, and see how a market moved.
A map is only useful if you can rank it, and that is where the thinking is. A site is not good or bad in the abstract. It is good or bad for somebody. So sites are scored per audience segment on things measurable about a location rather than numbers a media owner supplies: how well the area matches the segment, how dense the places around it are, how much traffic passes, how congested it is, and what venues pull people past in the first place.
Two deserve a note. Congestion cuts both ways, which is why it carries a negative weight for most segments and a positive one for commuters: a jam is dwell time if your audience is sitting in it. And the magnet list is what planners actually argue with, because "this screen scores well because of the stadium next to it" is checkable where a single number is not.
Pick a segment below and the ranking reorders. The same physical billboard moves several places depending on who you are trying to reach.
Factors and their weights
The same idea, pointed at whitespace
The second is a sibling of the first, built for a different question. Rather than scoring sites you already run, it holds the available inventory in a market as bookable stock: typed by format, tracked by whether it is available, booked or under maintenance, attached to campaigns with their assets, and enriched with what surrounds each site.
The layer that makes it worth using is beside the inventory rather than in it. Your own stores and your competitors' stores sit on the same map, so the question a planner asks stops being which billboard is free and becomes which free billboard is close to one of my restaurants and far from one of theirs. That is a whitespace question, and it is the same shape as the very first model I ever built at Salesdash, which is not a coincidence I noticed until I wrote this page.
It also has the thing every internal tool eventually needs and nobody enjoys building: an approval queue. New accounts land in a pending state and an administrator grants a role, because a tool holding a client's media plan should not be self-service.
A CRM is a different category of thing
A lead manager for a property developer. Enquiries arrive from social ads, get assigned to a salesperson, and move from new to contacted to qualified to won or lost, with follow-ups, an activity trail and automated messages along the way.
This is the one that taught me something, because a dashboard holds numbers and this holds people's names and phone numbers. Three decisions in it are mine and none were asked for.
Personal data is encrypted at rest, not merely access-controlled. Names and contact details are encrypted on write, decryption is privileged and revoked from anonymous access, and the app reads through views rather than the columns. A leaked read-only credential yields ciphertext, not a contact list worth selling.
Email goes through a queue with a dead letter queue behind it. Sending inline from a request handler works until the provider is slow, then fails quietly and nobody can say which leads were never contacted. A suppression list of unsubscribes, bounces and complaints is checked before anything is queued, because the fastest way to lose a sending domain is to keep mailing people who asked you to stop.
Step through it below, then break it. The failure paths are the point.
Lead captured
Asking the warehouse a question in English
Every question about performance used to pass through someone who could write SQL, usually me. A planner who needed one number waited a day for it, and I spent the day writing queries instead of building things.
So the question became the interface. Plain language in, a model plans a query against a documented catalogue of tables, the plan is checked before anything runs, and the answer comes back with a chart. The catalogue is the part that matters: every table written down with what it means and which questions it may answer. Two brands run the same skeleton and only the catalogue differs.
The bug that taught me the most had nothing to do with the model. Results were silently coming back empty: the code asked the warehouse once and read whatever came back, without checking whether the query had finished or asking for the next page. On a slow query the warehouse replies that it is not done yet, and the app reported that as a genuine zero. Correct query, no error anywhere, confident wrong answer. The fix is two lines and it took a week to find.
Ask it something below. Then turn on the switch marked read once and ask again.
Answer
Rewriting content for whoever is reading it
One piece of content often has to work for several audiences: a trade reader who wants detail, a general reader who wants the point, someone younger who wants both in shorter sentences. By hand that means writing it three times.
So the audience became an input. You give the tool the text and who it is for, which is platform, region, education level and age, plus a target reading grade and length, and a model does the rewrite.
Whether it landed is checkable, because reading level is measurable: the tool scores roughly twenty-five properties before and after. Pick an audience below and the same paragraph gets rewritten, both versions scored live. Then read what the score says about whether it hit what it was asked for.
Two faults, in the order I would fix them. The loop never closes: the model is asked for a grade it cannot measure, the app measures afterwards, and nothing retries on a miss, which makes the score a report card rather than a control signal. And on malformed JSON both versions fall back to the original text and report success, so a parse failure is indistinguishable from no change needed. Same shape as the empty-results bug above: the dangerous failures are the ones that look like answers.
Where a platform has no off-the-shelf connector, I write the small service that goes and gets the data. Where a report gets rebuilt by hand every week, I automate it so it just arrives. And there's a monitor that checks every morning whether yesterday's data actually landed, because finding a broken pipeline yourself is a much better morning than hearing about it from a client.
Then the job started changing.
Over the past year less of my time has gone on answering questions and more on building the things that answer them.
The conversational analytics product above is the clearest example, shipped as two brand instances of one system. The interesting problem was never the model. It was making the thing say I don't know rather than invent something confident and wrong, the same instinct that made me chase an answer of zero until it turned out to be an unfinished query.
Alongside that I set AI tooling standards for my team as part of an internal AI committee, and ran the evaluation on which platforms we should actually be paying for versus building ourselves.
This is the direction I want to keep going in, which is most of why this site exists.
The deeper end
A master's degree and a thesis, taken while working. This is where I find out what I actually believe about building software.
Back to the same university, this time with a job.
An M.S. in Applied AI taken alongside full-time work, which is a good filter. You stop doing the assignment that impresses and start doing the one you will still understand at eleven at night.
Two of those projects produced work I would defend in a room. One of them is the model I was proudest of, and it lost.
Chest radiograph triage
The task was abnormality triage on the MIMIC-CXR chest X-ray set: flag the films a radiologist should read first. 383 training images, 102 validation, 184 test, split by patient rather than by image so the same chest could not appear on both sides of the line.
Baselines first: logistic regression, k-nearest neighbours and a random forest on radiomics features. Then a fine-tuned EfficientNet-B4, which beat only one of them. Then the idea I liked best, a U-Net learning segmentation and classification together so the classifier had to look at the lung rather than the whole frame.
It scored 0.53, a coin toss. Two epochs on 383 images with masks that were not good enough. That is not evidence segmentation guidance is a bad idea, it is evidence I did not have the compute to test it, and those are different sentences. What worked was going wider rather than cleverer: fusing the image with the tabular features lifted the whole thing into the nineties.
The other lesson: AUC decides nothing. A triage tool needs a threshold, and the threshold is where the argument lives. Flag too eagerly and the radiologist reads the whole pile anyway. Flag too cautiously and an abnormal chest goes to the back of the queue.
Pick a model and drag the threshold. The curves are score distributions for normal and abnormal films; the counts are what a shift with 184 films would actually experience. Watch the missed column. Put the U-Net up and the curves sit almost on top of each other, which is what 0.53 looks like once you stop reporting it as a number.
What the shift actually sees
A graph recommender, and a number I would rather show than bury
Customers and products as two sets of nodes, a purchase as an edge between them, and a graph convolutional network learning to score the edges that do not exist yet. Trained on 272,404 real transactions across 3,647 customers and 3,538 products, early-stopped at epoch 21 with a best validation MSE of 0.1435, then measured against a NeuMF baseline and against what Neo4j returns for the same customer.
Precision at ten came out at 3.7%: roughly one recommendation in ten lands. Cold, that reads like failure. But on a catalogue of 3,538 items where the average basket holds a handful, random guessing gets about a tenth of a percent. So it is around thirty times better than chance and still nowhere near good enough to show a customer, and I need both halves of that sentence sayable out loud.
Pick a customer. Their purchases light up and the panel ranks what the co-occurrence baseline would show them next, with real product names from the catalogue.
Those names show what the model does and does not know. Customer 14258 gets LED string lights then a fringe table skirt: both party supplies, a sensible pair. Customer 13187 gets compression stockings, a paintball harness and a music box. The model has no idea what any of these objects are. It knows only which ones shared baskets, enough to find real structure sometimes and noise the rest of the time. That gap is what the precision number measures.
Recommended next
Licence plates that the off-the-shelf answer cannot read
Lebanese plates mix Arabic and Latin script on the same plate, and the colour encodes the vehicle class: white for private, red for taxis, green for rental. Every commercially available recognition system is trained on standardised Latin plates photographed in good light, so pointed at a Beirut street it does not degrade gracefully, it simply does not know what it is looking at.
The pipeline was detection then recognition: YOLO locates the plate in the frame, the crop goes to a recognition stage that reads the characters. Splitting it that way means the two failures are separable, and you can tell whether you failed to find the plate or failed to read it, which matters because they have completely different fixes.
The most useful thing the project produced was the log of frames where detection returned nothing at all. A recognition score averaged over the frames you managed to detect flatters you, because the hard frames quietly leave the denominator. Angled shots, motion blur and night plates are exactly the frames a real deployment is full of.
A pipeline that has to survive three in the morning
The data engineering term ended in something deliberately unglamorous: retail sales read from CSV, daily weather pulled from an API, both cleaned, merged and written into MongoDB as two collections, with an Airflow DAG running the whole thing at 03:00 and a Dash app reading off the result.
Nothing about that is technically hard, and that is the point of building it. Every interesting failure mode of a pipeline happens in the hour when nobody is watching. The API is down. Or it answers, but with a partial day. Or it renames a field and returns a perfectly valid response that means something different from yesterday. The job either notices, or it writes something wrong into a table that six dashboards trust, and nobody finds out until a number looks odd in a meeting.
So the transforms were the easy half and the logging was the half worth doing carefully. It is also the closest thing in the degree to what I do at work, which is why I chose it. I wanted the version of this problem where I owned every layer, including the one that has to survive three in the morning.
What transfer learning is actually worth
The computer vision term had a question I wanted a number for rather than an opinion: on a small dataset, how much does a pretrained encoder really buy you? So I built the same U-Net twice, once from scratch and once with a frozen VGG16 encoder and a mirrored decoder, and compared them on the same segmentation task.
The from-scratch version has to learn edges, texture and shape from a few hundred images. The VGG16 version already knows what an edge is, having been shown a million photographs of other things entirely, and only has to learn what to do with that knowledge. On a small dataset that head start is most of the performance, which is the argument for transfer learning in one sentence.
Building both is slower than reading a paper about it, and it is the only way the answer belongs to you. I would rather have measured it once myself than be able to cite it. The rest of the degree was recommender systems, data visualisation, statistics and a term on ethics I expected to resent and did not.
Grad school, and a voice agent that had to survive a phone call.
I started the M.S. in Applied AI at LAU in 2025, online, while working full time in Dubai. The thesis is LIRION: a modular real-time voice agent for telephony.
Voice agents that look fine in a browser demo fall apart on a phone call: a hard latency budget, real turn-taking, barge-in, and no visual fallback when something goes wrong. I picked it because it is the version of the problem where the shortcuts stop working.
The architecture is a Listen, Think, Act, Speak loop with a hard wall between dialogue reasoning and backend execution, and the wall is a schema. The model cannot emit instructions. It must return six fields: what to say, an optional action name, its parameters, a state update, a log update, and a flag that ends the call. The orchestrator reads nothing else, so an action the model invents is not in the dispatch table and dies there.
The rule I would defend hardest is the ordering. Execution resolves before the final reply is written. The tool result is appended to conversational state and handed back, so the model answers against a verified outcome rather than a prediction and a caller can never be told a booking exists before it does. Writes are gated on reads for the same reason: never book without checking availability, which makes double-booking structurally impossible rather than merely unlikely.
Pick what the caller says and run the turn, or step through it. The reschedule case is the one I would point at: it books the new slot before cancelling the old, because the other order means a failure halfway through leaves someone with no appointment at all.
Decision object
Answering in Arabic without a second prompt
A bilingual agent is usually built the expensive way, one prompt per language, drifting apart. LIRION does the opposite: the Agent always reasons and replies in English, and a translation layer localises on the way out. One prompt, one set of booking rules, one place to fix a bug.
Detection has a deliberate shortcut in front of it. Arabic script occupies a known Unicode block, so a regular expression catches it before any model is called, and the round trip disappears for the case that needs it most. Only ambiguous input falls through to a detection call, at temperature zero, because language detection does not benefit from imagination.
The translation prompt carries one instruction that matters more than the rest: preserve names, phone numbers, dates, times, emails and identifiers exactly. A translator that helpfully reformats 15:00 has silently corrupted a booking. Both functions fail safe, returning the original rather than an exception, because a translation failure should degrade a call rather than end one.
Asking the call data questions, safely
Once it had been taking calls a while, the interesting questions moved from the calls to the data behind them. So LIRION got a second agent: turn a business question into read-only SQL, run it, describe the answer. It reads a documented schema from a file rather than introspecting the database, so the model sees only the tables it should.
The part I would defend in an interview is that safety is not left to the prompt. The instructions say SELECT only, and then the code checks: the statement must begin with select, and is scanned for insert, update, delete, alter, drop, truncate and create before it nears a connection. Two independent guards, because a prompt is a request, not a constraint. Listings are capped, and an underspecified question gets one follow-up rather than a guessed time window.
Metrics that refuse to double-count
Every state change writes a row to an event table rather than only mutating the appointment. Ordinary event sourcing, but the taxonomy is where the thinking went. A reschedule emits two events, not one: the new booking and the cancellation of the old, each labelled as part of a reschedule rather than a fresh decision.
The naive version lies. Count bookings without that distinction and every reschedule inflates bookings while its cancellation deflates retention, so a busy week of customers moving appointments looks like churn and growth at once. Deciding what counts as an event is a measurement decision disguised as a schema decision, and getting it wrong is invisible until someone builds a dashboard on it.
And the parts nobody demos
A voice agent that only answers the phone is a prototype. What made this feel finished was the unglamorous surface: a dashboard on the event tables, a job emailing each staff member their next day's appointments, and a two-way Google Calendar sync so the people doing the work never open the system that booked them.
The calendar sync is the piece I would rebuild first. Two systems that both believe they own an appointment will eventually disagree, and reconciling that properly is a harder problem than anything in the conversational layer.
What I actually reach for.
Listed by how often I use it, not by how it looks on a CV.