Most of the work in Nakodo is a pipeline that keeps going: search, read profiles, score, find the published contact address, draft, send, follow up. The obvious instinct for a product like that is to keep it running as hard as the plan limits allow, because more leads must be better.
It is not better, and the clearest way to see why is to follow one lead through.
A campaign on the Pro plan sends 25 first emails each weekday. Suppose it has found 900 good fits. The 900th one gets emailed in about seven weeks. In those seven weeks the creator may have stopped uploading, changed direction, or already taken a sponsorship from a competitor. We re-read their data before using it, so the email is not wrong, but every minute of work spent finding them was spent early for no benefit. And it was not free: each one cost YouTube quota units, a web search against a shared daily cap, a website read, and an AI fit check against a per-day allowance.
So we built the opposite of a feature. A campaign decides it has enough and stops looking.
Enough has to be measured in the outflow
A constant would be wrong, because 200 leads is a fortnight for one campaign and two days for another. The only sane unit is the campaign's own sending rate.
Which means first explaining where that rate comes from, because it is not simply the plan's number. A plan's daily first emails are split between the account's campaigns, so one busy campaign cannot eat the whole day and leave the rest with nothing:
export function splitDay(limit: number, campaigns: ShareInput[]): Map<string, number> {
const set = campaigns.reduce((n, c) => n + (c.dailyNewEmails ?? 0), 0);
const unset = campaigns.filter((c) => c.dailyNewEmails === null).map((c) => c.id);
const rest = Math.max(0, limit - set);
const each = unset.length > 0 ? Math.floor(rest / unset.length) : 0;
const odd = rest - each * unset.length;
return new Map(
campaigns.map((c) => [c.id, c.dailyNewEmails ?? each + (unset.indexOf(c.id) < odd ? 1 : 0)]),
);
}
A campaign with a stored share keeps it. A campaign with null has never been given one, and shares whatever the others leave, evenly, with the remainder going to the oldest first so that 25 split between two campaigns is 13 and 12 rather than 12 and 12 with one email a day falling on the floor. Math.max(0, limit - set) is there for the downgrade case, where the stored shares already add up to more than the new plan allows.
Then enough is two days of that
export const STOCK_WEEKDAYS = 2;
export function stockTarget(s: LeadStock): { target: number; monthCapped: boolean } {
const days = STOCK_WEEKDAYS * s.share;
const month = s.queued + s.monthLeft;
return month < days ? { target: month, monthCapped: true } : { target: days, monthCapped: false };
}
export const hasEnough = (s: LeadStock): boolean => s.queued + s.ready >= stockTarget(s).target;
Two rather than one, and the reason is latency, not safety margin. A search does not produce good fits; it produces candidates that then need profiles read, websites crawled, contacts found and a fit check run, which takes hours. YouTube's quota resets once a day, so a search round that runs out of quota resumes tomorrow. And a round can simply turn up very little in a narrow niche. One day of stock would mean a campaign that dips to zero and sits idle while the replacement batch works its way through. Two days is the smallest number that covers one bad round.
A lead is counted in two states, and both are things that could go out tomorrow:
-
queued: a first email already written and waiting (indrafting,revieworqueued, and not yet sent) -
ready: a good fit with a published email that nobody has contacted
Counting only the second would make the campaign re-search while fifty finished drafts sit in the approval queue.
monthCapped is the Free plan. Free counts by the month, not the day, so if eight contacts remain this month there is no point holding 20 leads. The target becomes what is left of the month, and the flag exists so the UI can explain itself in the right words rather than reverse-engineering why the number is odd.
The degenerate case is the best part
Look at what happens when share is 0, which is a real state: a user can set a campaign's share to zero under Manage campaigns, parking it without pausing it.
days is 0. queued + ready >= 0 is always true. So the campaign has enough with nothing, and never searches.
That is exactly right, and nobody wrote a branch for it. A campaign with no daily emails that kept searching would pile up leads that provably cannot be sent, which is the original problem in its purest form. The arithmetic already knew. We only had to notice, decide it was correct rather than a bug, and write it down:
// A campaign with no new emails a day has enough with none: anything it found
// would only wait.
test("a campaign with no new emails a day has enough with none", () => {
assert.equal(hasEnough(s(0, 0, 0)), true);
});
Pinning a fallout like that with a test is the difference between a nice property and a property somebody deletes in six months while tidying up.
A hold is not a pause
The state lives in its own column:
enoughLeadsAt: timestamp({ withTimezone: true }),
findMoreUntil: timestamp({ withTimezone: true }),
The campaign's status stays active. It was tempting to reuse the pause machinery, and it would have been wrong for the same reason the pause carries both a mode and a reason: a pause is a thing somebody chose, and this is a thing the system is doing on its own and will undo on its own. Overloading status would mean a resume button that cannot tell whether there is anything to resume.
Instead the gate composes, as one extra condition on the existing one:
// isSearching(): finding, and not holding back with enough good leads waiting.
export const searchingSql: SQL = and(findingSql, isNull(campaigns.enoughLeadsAt))!;
The refresh runs at the top of each pipeline tick, and writes only on transitions:
for (const c of rows) {
const asked = c.findMoreUntil !== null && c.findMoreUntil > now;
const stock = asked ? null : await leadStock({ id: c.id, userId: c.userId! });
const enough = stock !== null && hasEnough(stock);
if (enough) holding.set(c.id, stock);
if (enough === (c.enoughLeadsAt !== null)) continue;
await db.update(campaigns).set({ enoughLeadsAt: enough ? now : null }).where(eq(campaigns.id, c.id));
if (!enough) released.push(c);
}
if (enough === (c.enoughLeadsAt !== null)) continue; is the whole idempotency story: comparing the computed boolean against the stored one means a tick where nothing changed issues no writes at all, and the released list, which the caller uses to queue fresh searches, contains only genuine edges rather than every campaign that happens to be low.
The override has an expiry, not a switch
Users want a "go and look anyway" button, and the lazy implementation is a boolean. A boolean is a trap: it gets switched on once, in a moment of impatience, and then the feature is off forever for that campaign and nobody remembers why that account burns twice the quota.
So it is a deadline:
export const FIND_MORE_MS = 24 * 60 * 60_000;
await db.update(campaigns)
.set({ enoughLeadsAt: null, findMoreUntil: new Date(Date.now() + FIND_MORE_MS) })
While findMoreUntil is in the future, the stock is not even computed (const stock = asked ? null : await leadStock(...)), so there is no chance of a half-applied override. A day later it lapses and the campaign goes back to managing itself. An override that expires is almost always the right shape, and it costs one timestamp instead of one boolean.
A feature that stops working has to say so
This is the part I would have under-built if I had not seen it on screen. A campaign that has quietly stopped searching is, from the user's side, indistinguishable from a campaign that is broken. Our own support load would have been entirely "it says active and it is not finding anyone".
So the hold renders a note, and the note contains a real sentence with real numbers:
export function weekdaysSpan(leads: number, share: number): string {
const n = Math.floor(leads / Math.max(1, share));
if (n <= 1) return "the next weekday";
if (n < 5) return `the next ${n} weekdays`;
if (n < 10) return "the next week";
return `the next ${Math.floor(n / 5)} weeks`;
}
assert.equal(
readyText(s(37, 27, 58), "businesses"),
"85 good fits are ready to email, enough for the next 2 weekdays at 37 a day",
);
assert.equal(
readyText(s(10, 1, 0, 0, 10), "creators"),
"1 good fit is ready to email, enough for the rest of this month's 10 new creators",
);
The buckets are deliberately coarse. Nobody wants "enough for the next 3.4 weekdays"; they want to know whether this is a day thing or a fortnight thing. Math.max(1, share) guards the division for the zero-share case above, which gets its own wording anyway. And both branches of readyText, the daily one and the month-capped one, are asserted character for character, because this string is the entire explanation of why the product looks idle. It is also the most likely thing in this feature to be broken by a well-meaning refactor, and a test on an exact sentence is a cheap way to find out.
The note also carries the Find more now button, so the explanation and the override are in the same place as the thing being explained.
One more asymmetry worth stealing
Back in the shares editor, changing a campaign's share is clamped:
export function clampShare(limit: number, shares: Record<string, number>, id: string, wanted: number): number {
const current = shares[id] ?? 0;
const others = Object.entries(shares).reduce((n, [k, v]) => (k === id ? n : n + v), 0);
const n = Math.max(0, Math.round(Number.isFinite(wanted) ? wanted : 0));
return n <= current ? n : Math.min(n, Math.max(current, limit - others));
}
Lowering a share always works. Raising one only works into room the others have left free. The asymmetry exists because of downgrades: an account that drops from Business to Pro has stored shares summing to more than the new plan allows, and a UI where every slider is stuck until you fix some other campaign first is a UI where the user cannot get out of the state they are in. So the rule is "you may always reduce, you may only increase into free space", and Math.max(current, limit - others) is what keeps a slider from snapping backwards when the total is already over.
test("over the plan's day, a share can still be lowered but not raised", () => {
const shares = { a: 40, b: 40 };
assert.equal(clampShare(25, shares, "a", 39), 39);
assert.equal(clampShare(25, shares, "a", 45), 40);
assert.equal(clampShare(25, shares, "a", 0), 0);
});
Any limit a user can exceed through no action of their own needs a rule like that, and the test for it is the one nobody writes until it has already gone wrong in production.
Go and look at the numbers it is built on
The daily figures the whole mechanism is denominated in are published, side by side, in the full limits table on nakodo.app/pricing: 10 new creators a month on Free, 25 each weekday on Pro, 75 each weekday on Business, along with the AI email drafts a day and the per-plan search allowances that make a lead found seven weeks early genuinely expensive. The FAQ on that page states the part that makes shares necessary in the first place, which is that the paid plans count by the day "across all your campaigns".
And the pacing it is balancing against is described on nakodo.app/how-it-works: emails go out on weekdays, in working hours in each recipient's own time zone, paced rather than sent in bulk. Two weekdays of stock is a number that only makes sense once you know that is the shape of the outflow.
Top comments (0)