A sourcing product, designed and shipped in 5 days

Passive Search · Cooper ATS · competitor research, product design and the front end build

TL;DR

Cooper’s ATS was built around applicants. The company was adding three AI agents, and passive sourcing was the highest- priority one—the only product that could stand alone for teams that never switched ATS.

I researched competitors, designed the experience and built the front end. The brief was a prototype; in one Monday-to-Friday sprint, I handed over working code that engineers could take, not interpret. By Friday, it behaved more like a beta than a rough first pass.

The shipped flow turns a plain-language hire into an editable structured search, runs it against public professional profiles, and streams deep research into the candidates worth keeping.

The lesson was simple: The interface can only promise what the data can pay for. That meant being honest about ranking on thin profiles and refusing a confident pool estimate built on a request that had quietly been failing.

Research to working product
5 days

Research to working product

Lines of front end I designed and wrote
~18k

Lines of front end I designed and wrote

Shipped bilingual, formal register
EN / DE

Shipped bilingual, formal register

The brief

Cooper is an ATS for hiring teams in Germany and the wider DACH region. Every surface in it assumes a candidate arrived by applying.

The economics are what made passive sourcing urgent. Active sourcing meant committing around €500 a search before a single profile appeared, and we had already shipped a version of it that customers liked and did not hire from. Passive sourcing costs cents, and the same index serves a customer in Berlin and one in Singapore. The incumbent licence for the same job runs to about €12,000 a year, with no AI on top of it.

Two instructions shaped the work. The first was speed over polish, said plainly: a feature that takes three months to design and six to ship does not grow the company. Part of that was explicit permission to abandon the design system and tidy up afterwards.

I did not take it. I had built that design system, and pulling the atoms and molecules straight out of the repo was faster than inventing replacements, not slower. The permission was aimed at the wrong layer.

The second was a process experiment. I had just rebuilt the marketing site by prototyping in code instead of Figma, and they wanted the same approach on a real product feature, possibly without opening Figma at all.

So I narrowed it.

Keep the atoms, the buttons and chips and tokens and type, so it still reads as our product. Throw away the composed components, because they were built for data we did not have and cannot hold the data we now do.

On Figma I landed in the middle. Prototyping moved into code, but Figma kept the two jobs it is still better at. Anything that needed variations side by side went there first: the candidate card, the type and colour on the profile panel, the whole experience section. Those were worked out in Figma and pushed back into the code. And because a codebase holds exactly one version of a screen, Figma also became the dated record of what was decided and when.

Design happened in code. Figma is where fine-tuning and versioning landed.

Role & scope

Role
Competitor research, product design, and the front end build. I owned the framing, the flows, the interface, and the code that shipped.
Working with
Cooper's two founders, in daily reviews across the sprint.
Timeline
One five-day sprint, Monday to Friday, August 2026. Competitor research and scoping at the front, design and build through to the end.
Scope
Landing, brief review, results and profile, credits. Plus the ranking model, the German localisation, and all of the motion design.
Built with
React, Tailwind on Cooper's token system, Radix primitives. Exa for people search and deep research.
Process
Prototyped in code, refined in Figma wherever variations side by side helped, then pushed back into the code and reviewed in the running app. The first coded prototypes were the hi-fi wireframes.

1: The competitors

I ran one brief through all three market leaders, using a role I knew well enough to judge.

MetaView

MetaView looked best and felt the most agentic. It reads your brief back as must-haves, nice-to-haves and red flags. If you do not respond within five minutes, it carries on with its own reading. Three or four minutes later, candidates arrive in card or table view.

Its refinement loop was the strongest. Mark five people yes, maybe or no with a reason and it re-runs, showing what changed. The cold start was harder: four panes compete for attention, long summaries invite skipping, and thin public profiles get reports that sound like assessments even when they are only paraphrases.

Juicebox

Juicebox skips chat. One line of English becomes editable filters, then Run Search gives you candidates in under a minute. It has the deepest filter set of the three. If MetaView is Canva, Juicebox is Figma.

The tradeoff is three places to inspect a profile instead of one good view. It also returns confident matches for nonsense, something I flagged as a problem and later had to reconsider.

Jack & Jill

Jack & Jill has the richest-feeling cards, with what someone wants, values and a shortlist already built. It looks like the best-designed product until you notice that its own active candidate network is mixed into the results. Those cards carry data a public profile does not.

Their profiles look richer because they have richer data, not because they have a better interface. Copy the layout without the dataset and you have copied the promise without the substance.

The screen everyone agreed on was Juicebox’s run-search state, because it sets expectations with no ambiguity. You can see what the system understood and what it is about to do, before it does anything. That screen is the direct ancestor of our review screen.

2: What the data would allow

Then we looked at what we actually had, and it reordered everything.

  1. 1

    The snapshot: roughly 100 profiles, ten seconds, 18¢

    One query fans out into complementary queries and comes back with a variable set of unique public profiles. A snapshot of roughly 100 costs about 18¢, around a tenth of a cent per profile. You get a name, location, photo, job titles, companies and dates. You do not get responsibilities, achievements or, for most people, skills. Nothing you could screen on.

  2. 2

    Deep research: about 1¢ a profile, and slow

    Enrichment returns something that reads like a résumé. Role-level detail, summaries, education, the bullet points that make a card worth reading. Ten of those cost roughly what a 100-profile snapshot costs.

  3. 3

    So the ranking has to happen before the enrichment

    You cannot sort on data you have not bought. Whatever orders the list has to work on the skeleton, which means the honesty ceiling of the whole interface is set by the cheap tier.

That constraint decided more of the product than any competitor did. The first ten results have to be right, because they are the only ten anyone will pay to enrich, and ranking badly means spending real money researching the wrong people. Screening could not run in v1, because Scout reads prose against criteria and before enrichment there is no prose. And skills could not be a hard filter, because most snapshot profiles do not carry them, so it became an optional signal ranked last. Someone with no skill tags and a stronger history should not lose to someone who filled in a form.

Reading the actual file

I took the real exports, a hundred snapshot profiles and ten enriched ones, and read the order we were about to ship. It was not a ranking. Ninety-three of the hundred matched only one of the complementary queries, so the relevance score was measuring how often and how highly the search engine had listed someone. Not years of experience. Not whether they were in Berlin. Not whether they were currently freelance, and therefore excluded by the brief that found them.

The work-history line is enough to do a real second pass before anyone is enriched. Every signal in it is readable, and every one of them lies in a specific way.

Location

Match on person location, job location, or any past role location.

Trap: One lead designer reads Germany on the profile and Berlin on the job. Checking a single field drops them.

Role family

Token buckets (product design, UX, lead, founder) rather than an exact title.

Trap: “Product Designer” does not contain the string “product design”. Exact matching quietly deletes the obvious people.

Years

Earliest start date to now, shown as a number on the chip.

Trap: Career changers pass on total years alone, so it has to be paired with recent titles.

In-title months

The union of intervals where the title was in family.

Trap: Summing overlapping roles instead of unioning them invented a designer with a 63-year career.

Last 7 years

Months in family within the last 84. The best silent seniority signal in the file.

Trap: It measures coverage, not level, so on its own it says nothing about how senior someone is.

Freelance now

Current title or current company only.

Trap: Almost everyone has freelanced at some point. Past freelance is noise; only current status is a filter.

Completeness

Has a title, has dated roles, has a photo.

Trap: Three people in the file have no title at all and one has no history. They render as blank rows rather than being caught.

Three layers, not a score out of 100

First apply the filters the user can see: location, role family, minimum years and current freelance status. Location can match any of three fields. Fail one and you miss the first ten, even if retrieval liked you.

Then sort the survivors. Keep retrieval as a starting point, then reward a current in-family title, seniority and coverage across the last seven years. Penalise profiles missing much of their basic history.

Screening, bullet matching and company size come later. Rank the hundred on the basic profile, then enrich the people already at the top.

What that does to the real rows

Doing this against real data rather than in the abstract produces arguments about named people, which is the point of doing it at all.

A Tesla lead designer sitting at position three stays if the role family includes Lead and UX, and drops if the chip means the literal string “Product Designer”. That width is a real decision, so it belongs on the chip where someone can see it, not inside a scoring function.

A self-employed profile at position eight drops as soon as freelance is excluded, and the query that found them had already asked for that.

A founding designer at a small AI company, ranked twenty-eighth, belongs near the top. Retrieval never boosted him. The skeleton finds him at once.

One candidate is 6.9 years by their own dates, and enrichment had rounded them up to 7+. Show the number, not the band.

Show the chips and one quiet why-line. If the first ten are wrong they change a chip. They do not sit and wait for the enrichment to finish.

The snapshot export, annotated: what each field can and cannot carry.
Filter chips as the visible ranker, with role family width made explicit.

3: Three directions, twice

Both major screens were decided the same way. Three directions, built on one canvas in the product’s real tokens, then one chosen and built properly. Building the options instead of drawing them is what made the choice honest, because what was wrong with the review screen was invisible in a wireframe.

The review screen

The brief was blunt. Competitor screenshots on one side, our screen on the other, make it premium and stop making it look like a form to fill in.

The useful move was refusing the framing. The screen was not ugly. The parser was already running, so the values existed, and then they were being wrapped in bordered inputs with labels above them, which is the universal signal for nothing here yet.

The screen is not ugly, it is mis-postured. Juicebox never asks. It asserts.

Direction A was the read-back: the brief as one readable sentence, Find senior product designers in Berlin with 5+ years in B2B SaaS, every value a chip with a popover and not an input border in sight. B was a confidence brief, the same content laid out as a document, with tags on values lifted verbatim and dashed underlines on the guesses. C put live redacted matches next to the brief so that editing became tuning, which needs a preview under 300ms or the liveness reads as lag.

Three grayscale directions for the review screen: read-back, confidence brief, and live matches.

A shipped, with B’s honesty signals folded in. Two fixes came free. The experience range slider died in favour of preset chips, and experience stopped defaulting to Any, which was the one starting value that filtered nothing at all.

One behaviour was added beyond the ask, and it is what made the screen demonstrable. Untouched criteria track the brief; hand-edited ones do not. Change the experience chip to 10+ and “Has at least 5+ years” rewrites itself, but a criterion you typed stays exactly as you typed it. Without that rule the demo contradicts itself the moment someone edits a chip on stage.

The shipped review screen: a plain-language brief read back as editable chips, with related skills and optional screening kept visible.

The landing screen

Three directions again: a composer above a sortable table, a persistent saved-search rail, or a board of prompt cards. The board was self-rejected on the way into the review, because showing the full prompt on every card was cluttered at eight rows and unusable at eighty.

The table won for a commercial reason rather than a layout one. Usage would be charged per candidate viewed, so showing how much of a two thousand person pool you have actually seen makes “show more” feel like progress rather than like spending.

I did not argue with the mechanic. I argued with what it rests on.

The saved-search table was picked; the rail and search-board directions were not pursued.

The bar only helps if its total is trustworthy. If “fetch 50 more” returns poor results after promising 2,400, people stop trusting the numbers in the table.

4: Motion, and why I built it myself

The landing page has one signature interaction. The composer morphs into a button as you scroll into the saved-search table, and back again when you scroll up. It took about a dozen rounds across two days, and it is the clearest argument I have for designing in code.

None of it can be specified in a prototype. The feel of it lives in how a wheel behaves on a trackpad with inertia still running, what happens when someone flicks and then immediately scrolls back, where a gesture actually ends. To get it right I had to change a curve, reload, and put my hands on it, forty or fifty times.

The landing screen gives the scroll morph somewhere to land.

The shape of it

One signature curve, no overshoot. Collapse in 320ms, restore in 420ms. The entrance gets the longer beat, because coming back to the composer is the moment worth the time.

The layers stagger rather than firing together, so the frame reads as one box changing shape. The heading leaves first at 160ms with an 8px rise, the composer contents clear at 120ms, and “New search” arrives 140ms after that. The table’s growth is not a second animation. It is the consequence of the hero shrinking, so there is one thing to watch instead of two.

Then it was tied to scroll, and made hybrid: scrubbed across the first 180px of wheel travel, snapped after. That is where the problems came from, and they are the same lesson at three different scales.

  1. 1

    Scroll-linked motion cannot have conditional dead zones

    I added a rule so that a short list would keep its composer, and once the table grew past the viewport it stopped consuming the wheel. The hero froze half collapsed with no way forward.

  2. 2

    The frame must never be empty

    The opacity ramps left a window where the composer and the label were both at zero. They cross over now, so there is always something in the box.

  3. 3

    Elapsed time cannot bound a gesture

    Restoring resets the table’s scroll, and those events re-fired the collapse immediately, so I locked it for 400ms. A fixed lock expires mid-flick, and trackpad inertia keeps firing for well over a second. The leftover momentum landed on the table that the morph had just moved under the pointer.

The rubber band

Scrolling to the table’s top used to snap the composer back. I added a rubber-band gesture instead: resistance builds as you pull, up to 24px, then returns firmly in 360ms with no overshoot.

A bouncy recoil would read as an invitation to keep pulling. This one has to read as a wall.

Three layers carry it: the rows stretch, the top edge darkens, and New Search swells two percent. The gesture expires after 2.5 seconds, so a delayed flick cannot trigger the morph.

Later I stretched only the rows—not the heading, count, filters, or column header—so the gap reads as a boundary rather than a hole.

The small ones

The composer had to own its own scroll. Pasting a long brief scrolled the page instead of the text. The fix ended up being that the whole composer absorbs the wheel whenever the pointer is inside it, footer chips included, because running out of text is not an instruction to collapse the page. It also grows a line at a time from 144px to 202px before it starts scrolling internally, measured against the real font metrics in the running app rather than guessed.

Long experience descriptions fade out on the third line with a Read more sitting on the fourth, so a card never ends on a sliced glyph. The top of the composer fades as you scroll it, and only once you have left the top, so the first line stays sharp while you are still typing.

Reduced motion is honoured throughout. The scroll morph jumps between its two states instead of scrubbing.

The chip opens into a short, explicit set of experience options.
The frame never sits empty; loading holds the place.

5: What shipped

Passive Search went up for team review on the Friday. Four screens, real search and real deep research, fully localised, behind a flag that keeps it inert until a backend endpoint exists.

It shipped bilingual, in the formal register the rest of the product uses. About fourteen terms are deliberately left in English (Scout, Credits, Shortlist, B2B SaaS, the seniority names) because those are the words German recruiters actually use. Translating them would have been the wrong kind of correct.

The part I am most pleased with is not a screen. The whole project was a bet on shipping fast, and a prototype that looks finished is the specific risk that comes with that bet. So the handover says plainly what is real and what is a stand-in.

People search

Real

Live, against a public people index.

Deep research

Real

Live, streamed in as each profile resolves.

Pool size

Conditional

A measured count when the counter is configured, otherwise a heuristic, labelled as an estimate everywhere it appears.

Scout verdicts

Simulated

A fixed sequence assigned by list position. Nothing reads the candidate against the criteria, so rewording one changes nothing, and the delay before results is a timeout rather than work.

Credit balance

Simulated

Held on the client, so it is not enforceable. The server has to own it before anyone is billed.

Plan checkout

Simulated

No billing provider, and the prices are placeholders until they are set against real research cost.

Three things have to change before this is a paid feature. Credits move to the server. Screening actually reads the candidate, because credits are being charged for a fixed sequence today. And the pool count uses the paid provider for any brief that mentions experience.