The actual problem was not the product
Chefshot makes commercial food photography without a studio. The product works. The hard part was never the generation model.
The hard part was that a restaurant owner does not wake up wanting better food photography. They want more orders. Their menu photos are, in their mind, fine — they were taken by a friend with a decent camera, or by a previous agency, or by themselves at 11pm after service. The gap between what they have and what changes their conversion rate is invisible to them, and no amount of advertising closes an invisible gap.
So the go-to-market could not be "here is a tool". It had to be "here is your photo, here is exactly why it is costing you orders, and here is what it would look like instead." Delivered to a specific restaurant, about their specific dish, before any conversation happened.
That is a data pipeline problem wearing a marketing costume, which is the kind I find interesting. This is how it works, what I would change, and — more usefully — the point at which it stops working.
The highest-leverage outbound message is not a pitch. It is a diagnosis the recipient cannot dispute, about something they own, delivered before they knew they had a problem.
Why manual outreach could not work
The obvious approach is a list and a person. Search Google Maps for restaurants in a city, open each one, look at their photos, write something. I did a version of this by hand to test the message, and the arithmetic kills it immediately:
- A metro area has thousands of qualifying restaurants. Even one niche in one city is hundreds.
- Judging a restaurant's photography properly — is the light flat, is the plating obscured, is the hero dish even in frame — takes one to two minutes of real attention.
- Writing a specific, non-templated observation takes another three or four.
That is five minutes per prospect for a message with maybe a 3% reply rate. The unit economics are absurd before you start, and they get worse as you scale, because attention does not parallelise.
The insight that made the whole thing buildable: the judgement step is the expensive one, and it is also the one a model can do if you give it the right image and the right standard. Not "write me a sales email" — that is the cheap part. The expensive part is looking at four photos of a burger and knowing which one is the worst, and why, in the vocabulary of commercial photography.
The pipeline, end to end
Six stages, and the order matters more than any individual one.
| Stage | What it does | The non-obvious part |
|---|---|---|
| 1. Search matrix | Generates city × cuisine × keyword combinations | The matrix is a business artefact, not a technical one — it encodes who is worth reaching |
| 2. Scrape | Apify pulls places matching each combination | Runs as batches through a loop, never as one giant query |
| 3. Deduplicate | Checks each place against the CRM before any paid API touches it | This is a cost-control mechanism disguised as a data-hygiene step |
| 4. Image triage | A fast vision model picks the worst food photo from each venue | The single most valuable component, and the one everyone skips |
| 5. Diagnosis | Dify, with a knowledge base of photography standards, produces the critique | Retrieval is what turns "this looks bad" into an argument |
| 6. Enrichment + CRM | Apollo fills in company size and the decision-maker; everything lands in Twenty | Runs in parallel with 4 and 5, because it has no dependency on them |
Stage 1: the search matrix is a strategy document
The first node in the workflow is a code node that builds a two-dimensional array:
cities × niches × qualifiers
Austin, Dallas, Houston × burger, sushi, fine dining × "authentic", "family owned"
Then a Split In Batches node emits one combination at a time to the scraper. Each combination becomes one Apify run.
The reason this matters is not the looping. It is that the matrix is where you decide who you are hunting. Cities where you have a case study, cuisines where your model performs well, qualifiers that correlate with an owner who actually controls the decision. Every one of those is a business judgement, and putting them in a code node at the head of the pipeline means the targeting decision is versioned, reviewable, and changeable in one place rather than scattered across a scraper's config file and a marketer's memory.
A mistake here multiplies through the entire pipeline. Ten thousand badly-targeted prospects cost ten thousand diagnoses and produce nothing.
Stage 2 and 3: deduplication is a financial control
The naive pipeline scrapes, then processes everything, then deduplicates at the end. That order is expensive, because "processing" means paid vision calls and paid LLM diagnosis on venues you already have.
So the check happens immediately after the scrape and before anything is paid for:
Scrape → for each place:
Twenty: Search Companies by PlaceId or domainName
├─ exists → drop (or bump Last_Scraped_At)
└─ new → enter the pipeline
Two details that took a revision to get right:
Match on PlaceId, not on name. Restaurant names are not unique and get re-formatted by every aggregator — "Joe's Burgers", "Joes Burger", "JOE'S BURGERS Austin". A name match either produces false duplicates (losing real prospects) or misses duplicates (paying twice). The Google Place ID is stable and is what the scraper already returns.
Fall back to domainName when PlaceId is absent, because some sources omit it. The fallback ordering matters: PlaceId first, domain second, and never name alone.
Stage 4: the part that makes the product work
This is the component I would defend hardest, and the one that most implementations of this idea get wrong.
The obvious approach is to take the venue's first Google Maps photo and send it to the diagnosis model. This fails for a reason that is obvious in hindsight: the first photo of a restaurant on Google Maps is almost always the exterior. The storefront, the sign, sometimes a parking lot. You then hand a model a picture of a building and ask it to critique the food photography, and it produces a critique of a building.
An earlier version used a fixed rule — "take the third image", or "take the first one tagged Food & drink". That is a lottery ticket. Sometimes the third image is the signature dish. Sometimes it is the bathroom, or the menu board, or a photo of a delivery bag.
So there is a triage step before the diagnosis, and its job is to choose as deliberately as a person would:
- Filter the scraped image list to those tagged
Food & drink, cap at four or five. That is the candidate pool, and the cap exists because the triage call is priced on input images. - Send the whole pool to a fast, cheap vision model in one call, with a prompt that inverts the usual instruction:
You are a demanding commercial food photography director.
Here are 4 dish photos from one restaurant.
Choose the one that shows a CORE DISH but has the WORST photography —
bad lighting, cluttered composition, dull colour.
Return JSON only: {"worst_image_url": "...", "reason": "..."}
- Normalise the chosen URL through an image proxy to a fixed size and format before it reaches the diagnosis model.
The inversion is the whole trick. Every other use of a vision model asks it to find the best image. Asking it to find the worst one turns out to be exactly the commercial question: a restaurant's worst hero-dish photo is the one costing them money, and it is the one your product can visibly fix.
The same pattern — let the model decide, but make it commit to a typed answer — is what makes an agentic workflow safe to put in production rather than merely impressive in a demo.
It is also cheap. A fast model comparing four images is a fraction of a cent, and it runs once per venue. Paying a few cents to avoid sending a photo of a parking lot to your expensive diagnosis model is not an optimisation; it is the difference between a demo and a product.
Stage 5: retrieval is what makes the critique an argument
Without a knowledge base, the diagnosis model produces plausible generic commentary — "the lighting could be improved, consider natural light." Every restaurant owner has read that sentence and ignored it.
The Dify agent runs with a knowledge base attached, containing two things:
- The standard, written down: what constitutes good commercial food photography in terms this practice actually holds — composition rules, lighting direction and its effect on perceived freshness, colour temperature and appetite response, why a 45-degree backlight separates a dish from its background.
- The case library: before-and-after pairs from real Chefcshot work, with what was wrong and what changed.
The prompt then requires the agent to do three things in order: retrieve the relevant standard, name which specific standard this photo violates, and estimate what changes. The output is no longer an opinion. It is a claim with a rule behind it, and the owner can check the rule against their own photo.
The retrieved standard is also what supplies the vocabulary — "lacks a 45-degree side-backlight, so the dish has no separation from the plate" is a sentence that sounds like it came from someone who does this for a living, because it did.
Stage 6: enrichment runs in parallel, not in sequence
Company size, estimated revenue, LinkedIn presence and the decision-maker's contact details come from a separate enrichment branch that fires at the same time as triage and diagnosis, because it has no dependency on either.
Then the CRM writes happen in a strict order, and the order is not optional:
- Company first, in Twenty, with the fundamentals attached.
- Person second, iterating over the decision-makers found, each bound to the company ID from step 1.
- AI Journey third — the diagnosis — bound to the same company ID.
Writing the journey before the company exists produces orphans you have to reconcile later by hand. It is a small thing that becomes a large thing at a thousand records.
The Apollo query deserves one note: filter by title at the API, not in code afterwards. Asking for Founder, Owner, General Manager, Marketing Director at the query level costs one request. Fetching everyone and filtering locally costs the same request and then hands you a result set you must paginate through.
Where this architecture breaks, and at what number
This is the part most write-ups omit, and it is the part that matters if you are deciding whether to build this.
The design above uses n8n's own loop node and a scheduled trigger. That is a good choice up to a point, and the point is calculable rather than a matter of taste.
At the safe processing rate — roughly 25 venues per five minutes, which is what you can do without tripping rate limits and while staying inside memory on a single instance — you get about 300 an hour.
| Volume | Time to process | Verdict |
|---|---|---|
| 1,000 venues | ~3.5 hours | Fine. A scheduled job overnight. |
| 10,000 venues | ~33 hours | Acceptable, but now you are running a multi-day job unattended. |
| 100,000 venues | ~333 hours, about 14 days | Not acceptable. |
Fourteen days of continuous execution on a single orchestrator is not a throughput problem, it is a durability problem. Any restart, any network interruption, any slow memory leak ends the run, and n8n's loop has no acknowledgement mechanism and no dead-letter queue, so resuming means working out by hand which batches completed. Raising concurrency does not rescue it: a single n8n instance is one Node.js event loop, and ten times the parallel HTTP calls does not block politely, it blocks entirely.
Three more ceilings appear at that volume, and each needs a different fix:
The CRM becomes the wrong place for raw data. Twenty is an application database — GraphQL resolvers, foreign-key cascades, soft-delete indexes, webhook listeners. If roughly 70% of 100,000 scraped venues are chains, closed restaurants or venues with no usable photo, then stuffing all 100,000 into it as a staging buffer grows the database by tens of gigabytes and degrades the indexes the sales team depends on. The buffer belongs somewhere else.
The image pipeline hits third-party walls. A hundred thousand venues at four to five candidate images each is roughly half a million image fetches, before any model call. Direct Google image requests start returning CAPTCHAs at IP level. Public image proxies rate-limit. Model gateways have organisational monthly ceilings and instantaneous tokens-per-minute limits that no amount of key rotation avoids.
Execution logs become a storage problem of their own. Hundreds of thousands of rows of execution history, mostly successful, will fill a mounted volume.
The upgrade path, when you get there, is a change of category rather than a change of settings:
| Thousands | Hundreds of thousands | |
|---|---|---|
| Work transfer | n8n loop nodes and polling | A real queue — Redis Stream, BullMQ, Kafka |
| Staging | CRM status field | A separate store: ClickHouse, or plain Postgres |
| Execution | n8n HTTP nodes in sequence | Stateless worker fleet, horizontally scaled |
| CRM's role | In the cleaning path | Only the ~15–20% that qualify |
| n8n's role | The computation | The control room: triggers, alerts, business routing |
The last row is the one people resist. n8n is an excellent business glue and a poor message broker, and recognising that is not a criticism of n8n — it is recognising that you picked it for a reason and the reason has a boundary.
What I would tell someone building this
Build the triage step first, even though it looks like a detail. It is the difference between a system whose output a restaurant owner finds unsettlingly accurate and one whose output they delete. It is also the cheapest component.
Make deduplication the second thing you build. Not because data hygiene is virtuous, but because it is the only thing standing between you and a bill for diagnosing the same restaurant eleven times.
Write the standard down before you write the prompt. If the reasoning behind a good photo exists only in your head, the model cannot retrieve it, and the output will be an opinion rather than an argument.
Treat the matrix as a strategy document. It is the only place in the pipeline where a business judgement is expressed, so it should be reviewable by a business person.
And decide your volume ceiling in advance. Every architecture has one; the useful thing is to know where yours is before you are fourteen days into a job that will not finish.
The honest limits of this design
Two things this system does not do, stated because a case study that only lists strengths is a pitch.
It cannot tell you whether the restaurant wants to be contacted. A perfect diagnosis delivered to an owner who is exiting the business is still noise, and no amount of enrichment data reliably tells you which one you have. The reply rate is a function of the recipient's situation as much as your message.
And it does not remove the need for a human in the loop at low volume. At ten prospects a day, a person reading the diagnosis before it sends is cheap, catches the model's occasional confident nonsense, and — more usefully — is where you learn what actually lands. Automating the send at that scale would remove your only source of feedback.
Where this is going
The current version is deliberately built to be thrown away in parts. The export, the triage and the diagnosis are the parts worth keeping; the orchestration around them is the part that will be replaced when volume justifies it. That is the right shape for a system whose job is to find out whether anyone wants the thing at all — and the answer to that question does not require an architecture that handles a hundred thousand records.
It requires ten, done well enough that someone replies.
Keep reading
- Human in the Loop AI Design: Review Scales Only When It Shows One Decision and Its Evidence2026-03-027 minAI Systems
- AI Workflow Observability Is a Release Gate, Not a Monitoring Feature2026-02-288 minAI Systems
- Proving AI Output Quality Without a Lab: Forty Archived Cases and a Replay Gate2026-02-068 minAI Systems
- Automation Handoff Failures: Why 40% Automated Can Be 0% Faster2026-03-088 minAI Adoption
- Build LLM Evaluation Sets by Sampling Production Logs, Not by Inventing Cases2026-03-188 minAI Systems