All posts
ExperienceFounding Engineer · Foosh AIApr 2025 – Present

Foosh: From One Prompt Box to Ten Thousand Rows

Building an AI workflow platform end to end. Why React Context could not keep up with React Flow, how run state moved from memory to DynamoDB to S3, why we started at five parallel rows, and what happened when an LLM started doing the QA.

25 min readMukesh Bishnoi
ReactNext.jsReact FlowZustandAWS Step FunctionsAI

Foosh is for companies that would otherwise book a studio.

You want a campaign for your product. The old way is a photoshoot. Studio, photographer, lights, model, retouching. Weeks of it. Next quarter you need a new campaign, so you do it again.

We build the other way. Upload the product once. Build a workflow. Generate the campaign. The output goes to the website, to ads, to banners, and to video.

I joined in April 2025 as the founding engineer. This is what the year looked like.

53 models wired in
18 node types
10,000 rows in one campaign
240 frames per video segment

Where I started#

When I joined, the product was about LoRA training.

The problem it solved:

  • A company has a product. A shoe, a bottle.
  • A general image model has never seen that exact bottle, so it cannot draw it.
  • So you train it. Upload photos of the SKU, train a LoRA on top of Flux.
  • Now the model knows your object, and you can put it in any scene.

I was doing frontend only then.

Training is slow. It is not a request that returns. It runs for a long time, and the user is sitting there wondering what is happening. So the model library page was really a status page. Which LoRAs are training, what stage each one is at, which ones are done.

The whole screen existed to answer one question. Is my model ready yet.

Why the product changed#

Two things happened together.

The models got better. Newer image models take a few reference images at generation time and give you your product back, correctly. No training run. No waiting.

And the business needed more than one feature. Training a LoRA per object is one thing. Customers wanted campaigns. Many images, many products, many formats, and video.

So the product moved. From "train a model on your object" to "build a workflow that makes the whole campaign".

Where I stopped being frontend only#

I built the workflow builder frontend first. Canvas, nodes, edges, the editor.

Once it was running we had about five models wired in. Then the bottleneck moved. Every new model was backend work. Every new node was backend work. Waiting for that half was slower than learning it.

So I went fullstack. After that I was adding models and nodes end to end, canvas to Lambda.

The app: three surfaces#

The first thing I built on the new platform:

  • Image library. Every image the workspace has generated.
  • Generation. Prompt in, generation showing as it runs.
  • Model library. LoRAs training, and the trained ones.

Each one had its own problem.

The library got heavy#

A real workspace holds thousands of images. Not hundreds. AI generated images are not small files.

What went wrong was ordinary:

  • scrolling was slow
  • images popped in late, after you had scrolled past
  • the tab kept eating memory the longer you stayed

Two fixes. Neither is clever.

Load a page at a time. The gallery pulls 20 at a time and fetches the next page when you are about 1000 pixels from the bottom. You never hold the whole workspace in the page.

Stop sending full size images to a grid. This was the bigger one. We put Gumlet in front of our S3 bucket. It returns the same image at whatever size we ask for, cached per size. The grid gets a 300px thumbnail at around 50KB, not a 4MB original that the browser then shrinks into a small box.

Four variants of every image:

Variant From Used for
thumbnail CDN, w=300 the grid, ~50KB
medium CDN preview
full CDN, quality=100 viewing one image
original S3 directly download

The last row cost us a bug. The CDN compresses whether you ask it to or not. So a download link pointing at the CDN gives the user a compressed file that they think is the original.

Viewing goes through the CDN. Downloading does not.

We poll, and that was on purpose#

Fire a generation, get an execution id, then keep asking if it is done.

We have polled since the beginning. We did not try websockets and give up.

  • On Lambda, holding a socket open means paying for a function that sits there doing nothing.
  • You also have to keep that connection alive across a system built to be stateless.
  • The job takes thirty seconds to five minutes. Nobody can tell the difference.

The generation is slow because the model is slow. A socket would not have made it faster.

Every component was calling axios#

This was the mess I cleaned up.

Every component made its own axios call. So there was no caching anywhere. Open a screen, it fetches. Go back, it fetches again. Open a campaign from last month, one that is finished and cannot change, and it fetches that too.

That last case is the point. Most of what this app shows is finished work. Asking the server for it every time is a slower page for no reason.

So it all moved to React Query, in one place. One axios instance, one query client, one set of constants, and service files split by domain. Components call a hook. They do not know axios exists.

What we got:

  • finished things stay cached
  • live things stay fresh
  • when something does change, you invalidate one key instead of hunting for every component that fetched it

Clerk, and the workspace mapping#

Auth is Clerk. The part worth saying is that we did not build our own idea of a team on top of it.

A Clerk organisation is the workspace. The org id is the workspace id. Credits, workflows, gallery, members and roles all hang off it.

There is no separate workspace table with its own ids to keep in sync. That kind of mapping drifts.

Roles are viewer, member, admin, owner. They are enforced on the backend, not hidden in the UI. A viewer cannot run a workflow, and that check happens where credits get deducted.

The workflow builder#

This is the piece I spent the most time on.

Instead of one prompt box, give people a canvas. Drop nodes, wire them together. Each node is a model call or a piece of logic.

A real workflow looks like this:

product image → enhance the prompt with an LLM → generate 4 variations
              → upscale the good one → drop it into a banner template

Save it, and run it again next month with a different product.

It runs on React Flow. Today there are 18 node types and 53 models behind them, across nine providers.

One node component, not eighteen#

There is no ImageGenerationNode component. There is no VideoGenerationNode component. There is one DynamicNode, and it reads what to render from config.

  • The backend has one config file describing every node type. Its inputs, its parameters, its sockets, its outputs.
  • The frontend fetches that config and builds the node from it.
  • Adding a model is a config change. Nobody writes a React component for it.

Two contexts#

State started in React Context, and we had two on purpose:

  • the save flow — the graph itself. Nodes, edges, positions, parameters. The thing that gets persisted.
  • the execution flow — what is happening now. Which node is running, what came back, what failed.

Splitting them made sense. They change at different rates. The graph changes when you edit it. The execution state changes every time you poll.

That part was fine. The problem was somewhere else.

The real problem was two owners#

React Flow already keeps the graph in its own internal store. It has to. It is the one drawing the canvas and handling the drag.

So the moment we also kept the graph in context, two things owned the same data, in two different shapes:

  • our shape: model, parameters, sockets, outputs
  • React Flow's shape: position, type, data

Something has to transform between them. That transform sat in the middle:

Context → Transform → Sync → ReactFlow

Now drag one node:

React Flow updates its own store, tells us
→ we update our state
→ the transform runs, produces a new nodes array
→ we push that array into React Flow
→ React Flow sees a new array, treats it as a change, tells us
→ repeat

That is a loop. To stop it you add:

  • a guard flag, so we can say "this update came from me, ignore it"
  • a hash check, because you need to know if the incoming array is actually different or just a new array with the same contents

So on every render we were serialising the entire nodes array and comparing it. Twice.

What the lag looked like#

Dragging fires a change event on every mouse move. Sixty times a second.

You cannot rewrite your state, run the transform and hash the whole graph sixty times a second. So we debounced position updates by 300ms.

That debounce is what you could see:

  • React Flow paints the node under your cursor straight away, because it owns the canvas
  • our state was a third of a second behind
  • so while you dragged, anything reading our state saw the old position. The inspector, connected nodes, validation
  • drop the node and everything snapped into place

Measured: about 350ms for a drag to settle, and 20 to 50 re-renders for one node moving. That is what context does. Change the value and every consumer re-renders, and every node on the canvas was a consumer.

Around 150 lines existed only to keep the two copies agreeing.

The fix was to stop being the second owner#

We moved to Zustand. Not because Zustand is fashionable. Not because context is bad.

Because React Flow's internal store is already Zustand.

That is the whole reason. We were not picking a state library. We were choosing to stop fighting one that was already there.

before:  Context → Transform → Sync → ReactFlow
after:   Zustand store ═══════════════ ReactFlow

A drag now goes: React Flow reports the change to a handler that belongs to our store, the store updates both shapes in the same action, and only the components subscribed to that node re-render.

  • Nothing gets pushed back in, so there is no loop
  • No loop, so no guard flag and no hash
  • Zustand subscribes per selector, so a component asking for one node re-renders when that node changes, not when anything moves
  • The debounce went away, because there was nothing left to protect

If the library you build on has already made a state decision, the cheapest path is usually to make the same one.

I wrote the long version of this one up separately — the Context code that broke, why splitting the providers and adding memo did not save it, why Redux lost to Zustand, and how the two stores talk to each other: Why We Use Zustand with React Flow to Manage State.

Nobody checks ten thousand images by hand#

Once campaigns arrived, one run could produce thousands of outputs. Each one still needs a human decision. Good enough to publish, or not.

That does not scale. So there are two layers of QA, and the second only works because of the first.

The QC node#

QC is a node you drop in after your generation nodes. It takes the output, optional reference images, and your criteria. An LLM looks at each output and says accepted or rejected, with a reason.

Two details matter more than they look.

It does not filter. It does not throw away rejected outputs. It attaches its verdict and passes everything through. A model that silently deletes your work is not something you want in a pipeline. It labels. You decide.

The criteria are just text. The customer writes what they want in plain English. The label must be readable. The bottle must match the reference. The logo must not be cropped.

We do not make them build rules in a rule builder. "Does this image look right" is not something you can write as a rule. That is why an LLM is doing the job. We have our own QC rules underneath, and when the node runs we take what the user typed and turn it into the rules for that run.

It runs on Gemini 3 Flash, with GPT-5.2 as fallback if Gemini fails three times.

Keeping the verdicts small#

Two problems here, both only visible at bulk scale.

The verdicts are too big to store inline. Every output gets a verdict and a written reason. Across a campaign that is a lot of text inside a database record that has a size limit. So the full result goes to S3, and DynamoDB keeps only the key. About 50 bytes per item instead of the whole thing.

The verdicts have to land on the right image. If a node made four images and QC evaluated four images, verdict three belongs to image three. Exactly, not roughly. So indexes are preserved the whole way through, and reason ids are typed per media so an image verdict and a video verdict cannot collide.

That sounds obvious. It is the thing that quietly breaks when arrays get flattened and re-aggregated across a distributed run.

Then the human layer#

After the AI labels everything, the customer reviews.

  • They can go through every output.
  • Or filter to only what the AI accepted.
  • Each output can be approved or rejected with a reason.
  • The download can be limited to approved ones.

The reviewer never starts from nothing. The AI's verdict and reasoning are already on each output. So a human is confirming or overruling a first pass, not forming an opinion from scratch on image four thousand.

What actually happened#

This is the real result, and I did not expect it.

It goes in stages:

  1. At first the customer checks everything. They do not trust it yet.
  2. Then they look only at what the AI accepted, because that is where the usable images are.
  3. Then, once their rules have held up run after run, they stop opening the folder.

The QC node did not save time by reviewing faster. It saved time by letting people stop reviewing.

That only happened because the node labels instead of deletes. Being wrong stayed visible, so trust could be earned instead of assumed.

Giving the canvas to people who do not want it#

The canvas is good if you like building things. Most people do not. They want their campaign.

So a workflow can be published as an app.

  • When you publish, you choose which inputs the user fills in and which outputs they get back.
  • Those two choices become a form.
  • The user sees the form. Product image, tagline, press go, get the banner.
  • No nodes. No wiring. No idea a graph exists behind it.

That user is sometimes the customer, and sometimes a colleague of the person who built the workflow. The marketing person who is never going to learn a node editor and should not have to.

The builder stays for people who build. The app is for everyone else.

Every published agent is a public page#

A published app gets its own public URL, and that page has to work for someone who has never logged in. Which means it has to be a real page, not an empty shell that fills itself in after the JavaScript loads.

This is why the frontend is Next.js.

  • The page is rendered on the server, so the agent's name and description are in the HTML.
  • Each agent gets its own title, description, and Open Graph and Twitter cards, built from that agent's own data at request time.
  • Those responses are cached for a few minutes, so a popular agent is not hitting the API on every visit.

The practical result: paste an agent link into WhatsApp or Slack and the preview shows that agent, with its own thumbnail and description. Not a generic card with the company logo.

Same for shared workflows. Each share link is its own page with its own metadata.

Every agent someone publishes is a page that can be found, linked and previewed on its own.

Then somebody wanted five hundred at once#

One at a time is a demo. The real job is a catalogue.

So, campaigns. Upload a CSV, one row per thing you want made. Each row names the app to run and the values to fill in. Up to ten thousand rows.

That is where this stopped being a web app and became a queue.

Twenty at a time, and we started at five#

Rows run in parallel, but not all at once. Today it is twenty. We started at five.

The limit was not the models and it was not cost. It was Lambda concurrency.

  • Every row is a workflow.
  • Every node in that workflow is a Lambda invocation.
  • A few hundred rows at once becomes a very large number of concurrent functions, very fast.

We were tripping the account limit and taking down everything else with it, including workflows that had nothing to do with the campaign.

So we kept it low and moved it up as we learned the real ceiling. Five, then more, now twenty, with Step Functions Distributed Map fanning out and keeping concurrency capped.

Nothing clever about the number. The clever part was accepting that a slow safe setting beats a fast one that takes the platform down.

Validate before you charge anyone#

The CSV is checked before anything is charged and before a single row runs.

  • Do the named apps exist in this workspace
  • Are the required columns there
  • Are they filled in
  • If an app name is close but not exact, we say which one we think they meant

The reason is simple. A ten thousand row campaign that dies on row four hundred because of a typo in a header has already spent real money.

Credits work like a hotel deposit:

  • We take the full estimate up front as one atomic reservation.
  • If the workspace cannot cover it, the campaign never starts. Nothing is half charged.
  • At the end, whatever was not used is refunded against the original transaction.

Rows fail. A model times out, a provider errors. Those rows get a record straight away with the actual error on it. Then you rerun that row, not the campaign.

Where virtualisation actually mattered#

A finished campaign has thousands of images. The reviewer scrolls a filmstrip through them.

This is where we needed real virtualisation. Only the rows in view exist in the DOM. The rest is space.

The gallery gets away with paging 20 at a time because you scroll it slowly and look at things. A review filmstrip is different. People drag through it fast, all the way, repeatedly. Paging is not enough when someone is scrubbing.

Two different scrolling problems, two different answers. Worth saying, because "just virtualise everything" is the usual advice and it is not free.

Downloads had to leave Lambda#

Zipping thousands of full size images does not fit in a Lambda. It is not a memory problem, it is a time problem. Lambda has a hard ceiling on how long it may run and a big download goes past it.

So downloads run on ECS Fargate, where nothing times out.

  • The zip is split into parts at about 1.5GB
  • A folder is never split across two parts. Unzipping half a folder from part one and half from part three is miserable
  • Finished downloads are cached, but the cache checks whether the campaign was touched after the zip was built. Rerun a row and the old zip is no longer the truth

What actually runs a workflow#

A workflow is a graph, and a graph is not a shape a web request handles. Some nodes wait on others. Some run side by side. One node can take five minutes because a video model is thinking.

So execution runs on AWS Step Functions, with Lambda doing the work.

There is one state machine, not one per workflow. We do not generate a machine every time somebody saves a canvas. The machine is generic:

read the graph → work out what can run now → prepare each node's inputs
→ invoke the right Lambda → write the output back → repeat

The graph is data. The machine that walks it stays the same.

The run state moved twice#

This changed the most, and each move was forced.

Stage one: in memory. Execution state travelled through the run. Each step handed its outputs to the next. Simple, no storage, nothing to clean up.

It fell over as soon as workflows got real. Outputs are not small. A text node returns paragraphs. An image node returns URLs and metadata. Chain six nodes and you are carrying all of it along. Everything had to stay small enough to pass along, and it stopped being small.

Stage two: DynamoDB. So run state moved into a table. Each node writes its output to the execution record. Anything that needs it reads from there.

  • Nothing is carried around any more
  • The frontend polls the same record
  • A run that dies leaves its state behind, so you can see how far it got

That worked for a long time. Then the next wall: a DynamoDB item cannot be bigger than 400KB. A workflow with a lot of nodes, each holding its outputs, passes 400KB more easily than you would think. And the failure is ugly. The run does not slow down. It refuses to write.

Stage three: S3, with a pointer. Now, when node data is too big for the item, it goes to S3. What stays in DynamoDB is a small reference saying this lives in S3, here is the bucket and key.

The important half: every path that reads node data resolves that pointer first. The part that prepares inputs, the part that finalises the run, the part that answers the poll. All of them ask "is this a pointer" before using it. Nothing downstream needs to know. The frontend has never seen an S3 reference.

Three stages, same pattern each time. State starts wherever is easiest, then moves outward as it outgrows the container it is in. Memory, then a row, then a bucket with a pointer where the row used to be.

Two nodes finishing at the same moment#

Parallel execution brought its own bug.

If two nodes finish together and both write to the same execution record, one can quietly overwrite the other. Nothing errors. You get a workflow that reports success with one node's output missing.

So the record carries a version. You write on the condition that the version is still what you read. If someone got there first, re-read and try again.

Ordinary optimistic locking. Worth mentioning because this bug is invisible until a customer asks why an output is missing from a run that said it succeeded.

Running one node#

Small thing, big difference.

You are building a workflow. You tweak a prompt on node six. You want to see what node six does now.

You should not have to re-run nodes one to five. They have not changed, and they cost money.

So you can run a single node. Everything else keeps the outputs it had. Its inputs come from the nodes already feeding into it.

One rule: you cannot run a single node while the whole workflow is running, because both would write to the same place. Two separate nodes at once is fine.

The video pipeline#

One client needed something the platform did not do. Take videos their users had already recorded, and put them somewhere else. Same person, same performance, new background.

Almost every decision in it comes from one constraint.

Everything is shaped by 240 frames#

The model doing the background replacement will not take a video longer than 240 frames. At normal frame rates, about ten seconds.

Real videos are minutes long. So step one is not clever, it is forced. Split the video into segments that fit under the limit.

Everything after that exists because we cut the video up and have to put it back together.

Each segment needs to know the new background#

The replacement model needs a reference image showing the scene we want.

So we generate those, one per segment, using Gemini and Nano Banana. Take a frame from the segment, compose the person into the chosen setting, and that image becomes the instruction for what the segment should look like.

Then each segment goes through background replacement on its own, in order.

Putting it back without the jump#

This took the most fiddling, and not for the reason you would expect.

Cut a video into ten second pieces, process each separately, join them end to end, and you can see every joint. Each segment was processed on its own, so lighting or framing lands slightly differently. The eye catches every one of those small shifts.

The fix is to overlap segments by 60 frames and fade across the overlap.

The shift is still there. You just cannot see it, because it has been spread over two and a half seconds instead of landing between two frames.

Audio is cut at the midpoint of the same overlap, so one segment hands over to the next without a gap and without both playing at once.

Captions go on at the end, transcribed with Sarvam.

It has to survive being interrupted#

A long video becomes a lot of segments. Each takes real time. A full run is long.

So the pipeline keeps a progress file and marks each segment done as it finishes. If it dies in the middle, and over a run that long it will, starting again picks up from the first segment that is not done.

Any pipeline that runs for hours needs this. It is always tempting to skip until the first time you lose an hour of work.

Two smaller things#

Model output has to be data, not prose#

A lot of nodes call an LLM. An LLM left alone writes you a paragraph.

That is useless in a workflow. The output is not read by a person. It is saved as the node's output and wired into the next node. Something has to pick it up, store it, and hand the right piece to whatever comes next.

You cannot do that with a sentence. If a node returns "Sure! Here are three taglines for your product: ...", there is nothing to grab. Which part is the tagline. Are there three. Where does one end.

So nodes ask for structured output. A defined shape, every time.

The failure mode without it is the real argument:

  • the node does not fail
  • it returns its paragraph and reports success
  • the break shows up two nodes later, where something used a field that was never there

The error appears far away from the thing that caused it. In a graph, that is the worst kind of bug to chase. Structure at the boundary means a bad node fails at itself.

Bring the assets from where they already are#

Products come with photos, and every product needs several. Front, back, angles, packaging.

The entity library is where those live. An entity is a product or a brand. A name, and the images that belong to it. A workflow then refers to it by name. You do not attach files to a workflow. You say which product this run is about.

The upload is the part worth mentioning.

In this industry everybody's product photos are already in Google Drive, in folders, one folder per product. That is just how it is done.

So we read the Drive folders directly. Point us at the parent folder, we iterate through it, and each folder becomes an entity with that folder's name and the images inside.

The alternative is what they were doing before. Download everything from Drive to your laptop, then upload into our system one file at a time. For a few hundred products that is a day of somebody's life, spent doing nothing.

The feature is not bulk upload. The feature is not making somebody restructure their filing to use your product.

What I take away#

Two things.

Almost none of the hard problems were AI problems. Calling the models was the easy part. The hard parts were ordinary engineering. Who owns the state. Where it lives when it outgrows the row. How many things can run before the account falls over. Whether anyone trusts the output.

The limits designed the system. Very little here came from a whiteboard.

  • 240 frames is why the video is split.
  • 400KB is why run state went to S3.
  • Lambda concurrency is why we started at five parallel rows.
  • React Flow owning its own store is why we moved to Zustand.

Every one of those is a ceiling somebody else set. The work was noticing the ceiling early and building with it instead of against it.

I started this year doing frontend. I finish it having built both halves, mostly because waiting for the other half was slower than learning it.

Common questions

How do you run a node-graph workflow on AWS?

We use one generic AWS Step Functions state machine with Lambda doing the work, rather than generating a state machine per workflow. The machine reads the graph, works out which nodes can run now, prepares each node's inputs, invokes the right Lambda, writes the output back and repeats. The graph is data; the machine that walks it never changes, so saving a new canvas does not deploy anything.

Why does a DynamoDB write fail on a large workflow run, and how do you fix it?

A DynamoDB item cannot exceed 400KB, and a run record holding every node's output passes that more easily than you would expect. The failure is abrupt — the run does not slow down, it refuses to write. The fix is to put oversized node data in S3 and keep a small pointer in the item, then make every path that reads node data resolve that pointer first, so nothing downstream needs to know where the data actually lives.

Why would two parallel nodes lose each other's output in a workflow run?

Because both write to the same execution record and one silently overwrites the other. Nothing errors, and the run reports success with an output missing. The fix is ordinary optimistic locking: keep a version on the record and write on the condition that the version is still what you read, re-reading and retrying if someone got there first.

Should you use polling or websockets for long-running AI generations?

We have polled from the beginning and would again on this architecture. On Lambda, holding a socket open means paying for a function that sits doing nothing, and keeping that connection alive across a deliberately stateless system is work. Generations take thirty seconds to five minutes, so nobody can tell the difference between a socket and a poll — the model is what is slow, and a socket would not make it faster.

Two ordinary fixes did it. Page the data — we fetch 20 at a time and load the next page about 1000 pixels from the bottom, so the whole workspace is never in the page. And stop sending full-size images to a grid: a CDN in front of the bucket returns a 300px thumbnail at around 50KB instead of a 4MB original the browser then shrinks. One warning — a CDN compresses whether you ask it to or not, so downloads have to bypass it or users get a compressed file they think is the original.

Thanks for reading. Questions or pushback are welcome — I'm reachable on LinkedIn.

All posts