Our customers pick how much of a reply we handle, and a downgrade moves them back a level on its own
Nakodo emails creators on a brand's behalf and introduces the brand to the ones who say yes. The brand never sees the addresses, so every reply lands on us first, and the only interesting product question is how much of
Nakodo emails creators on a brand's behalf and introduces the brand to the ones who say yes. The brand never sees the addresses, so every reply lands on us first, and the only interesting product question is how much of that reply we are allowed to answer without asking.
There is no good single answer. A brand running its first campaign wants to read everything. A brand on its fifth wants to see the yeses and nothing else. So the amount is a per campaign setting with three values, and the whole thing is 34 lines:
export const REPLY_LEVELS = ["introduce", "answer", "negotiate"] as const;
export type ReplyLevel = (typeof REPLY_LEVELS)[number];
export const DEFAULT_REPLY_LEVEL: ReplyLevel = "answer";
export const LEVEL_NAMES: Record<ReplyLevel, string> = {
introduce: "Introduce me",
answer: "Answer their questions",
negotiate: "Agree the fee",
};
introduce introduces anyone who wants to go ahead or asks how it works, with the campaign details attached, and the brand takes it from there. answer also answers questions out of the proposal the brand approved. negotiate also agrees the fee inside limits the brand set. The public wording of all three is on how it works, and the fee one is a Business plan line.
Two things can take a level away
A business campaign has no creator fee to agree, so negotiating is meaningless there. And agreeing fees is on one plan, so a Pro customer cannot have it. Both of those can become true after the choice was made: a campaign's kind is fixed, but a plan is not, and a Business customer who moves to Pro still has replies: "negotiate" sitting in their campaign settings.
The obvious fix is to write the column back during the downgrade. We did not, because then every path that can lower a plan has to remember to do it: the Stripe webhook, a cancellation at period end, a failed payment, a comp being removed, a manual change in the admin panel. Forget one and a campaign keeps negotiating on a plan that does not include it, which is the one failure mode here that spends somebody else's money.
Instead the level is resolved on every read, and the stored value is only ever what the customer chose:
export const levelsFor = (kind: CampaignKind): ReplyLevel[] =>
kind === "creators" ? [...REPLY_LEVELS] : ["introduce", "answer"];
export function replyLevel(
settings: OutreachSettings | null | undefined,
kind: CampaignKind,
limits: Pick<PlanLimits, "negotiation">,
): ReplyLevel {
const chosen = settings?.replies ?? DEFAULT_REPLY_LEVEL;
if (chosen === "negotiate" && (kind !== "creators" || !limits.negotiation)) return "answer";
return chosen;
}
Two properties fall out of that. A downgrade is safe without touching any campaign row, and an upgrade restores the old behaviour without touching one either, because the choice was never overwritten. ?? DEFAULT_REPLY_LEVEL is also the entire migration for campaigns created before the setting existed: replies is one optional field inside a settings JSON column, and a campaign without it reads as the middle level, which is exactly how campaigns behaved before there was a choice.
The test is four assertions and no database:
assert.equal(replyLevel(settings("negotiate"), "creators", { negotiation: true }), "negotiate");
assert.equal(replyLevel(settings("negotiate"), "creators", { negotiation: false }), "answer");
assert.equal(replyLevel(settings("negotiate"), "businesses", { negotiation: true }), "answer");
assert.deepEqual(levelsFor("businesses"), ["introduce", "answer"]);
The level only touches two branches
A reply is read by a model into a classification with an intent, a confidence and some extracted fields, and then a pure function decides what happens. That function is a switch on intent, and only two of its arms care about the level at all:
switch (c.intent) {
case "opt_out":
// Stopping costs little when wrong; mailing someone who said stop costs a lot.
return sure(0.4) ? { type: "opt_out" } : ask();
case "not_interested":
return sure(0.7) ? { type: "decline" } : ask();
case "interested":
return sure(0.7) ? { type: "handoff", note: answer } : ask();
case "question":
case "wants_details":
if (level === "introduce") return answer && sure(0.7) && onItsOwn ? { type: "handoff", note: answer } : ask(c.brandQuestion);
if (answer && sure(0.75) && opts.aiAnswersSoFar < MAX_AI_ANSWERS && onItsOwn) return { type: "answer", body: answer };
return ask(c.brandQuestion);
// ...
}
Every arm has the same escape hatch. ask() produces { type: "needs_you" }, which puts the conversation in front of the brand with a one line question. Not sure enough, no level for it, out of answers: all of them end in the same place, and the brand sees the thread it would have seen anyway. There is no branch that quietly does nothing.
Three details in there are worth their own line.
The confidence thresholds are not one constant. Opting someone out on a 0.4 reading is cheap when it is wrong, because the cost is one creator who never hears from this brand again. Declining or introducing on a 0.4 reading is not cheap, so those want 0.7, and answering in the brand's name wants 0.75. The number is set by what a mistake costs, not by how hard the classification is.
MAX_AI_ANSWERS is 3. After three answers of its own in one thread, the next reply goes to the brand whatever it says. A conversation that needs a fourth answer is not a conversation that is going well.
And onItsOwn is the one rule I would keep if I had to delete the rest:
// brandWrote: the brand has written in this conversation itself, so it's
// handling it and Nakodo doesn't answer on its own any more.
const onItsOwn = !opts.brandWrote;
The brand's address is in copy on the introduction, and nothing stops a brand from replying into a thread earlier. The moment they do, two senders are writing to one creator with no idea what the other just promised. So a single message from the brand in a thread switches every automated answer off, permanently, at every level. It is not a setting and there is no way to turn it back on.
One more arm that ignores the level entirely:
// Headers say a machine wrote it: never answered, whatever the text says.
if (opts.automated && c.intent !== "bounce") return { type: "snooze", returnDate: c.returnDate };
An out of office is often a warm, detailed, enthusiastic looking piece of text. The decision is made from the headers before the text gets a vote.
What is not in this post
The classifier's instructions, the intent list's exact semantics and the fit scoring are not published, and the level never reaches the model as a free text instruction: it selects which instruction block is used, and the code above decides what happens to the result. The model reads and drafts, the switch decides, and the limits in the negotiate level never appear in a prompt at all. That last part needs its own post.
If you want to see the promises this code exists to keep, they are public: the three levels in prose on how it works, the sentence "A fee Nakodo agrees for you is never more than the limits you set" in the terms, and "your replies are read by software, including AI, to answer your questions from what the brand told us" in the privacy notice. Writing the promise down first is a surprisingly good way to find out which branch you forgot.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.