DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our negotiation ladder is a pure function with four moves, and the model that writes the email never sees the limit

On our Business plan, Nakodo can agree a creator's fee for a brand by email. The brand sets the most it will pay for each size of creator, optionally a budget for the whole campaign, and then goes back to work. A creator writes back asking for more than the first email offered, and something has to decide what to say.

That something is 114 lines of pure TypeScript with no network, no database and no model in it. The model gets involved one step later, and by then the number has already been chosen. This is a tangent to an earlier post about where the first fee comes from; that one was about the opening offer, this one is about the back and forth.

Four moves

export type Move =
  | { type: "accept"; fee: number } // agree: their ask, or the offer when they asked for less
  | { type: "counter"; fee: number; final: boolean } // offer this instead
  | { type: "ask_brand"; fee: number } // their ask is just over the limit: the brand decides
  | { type: "decline" }; // too far apart: a polite no
Enter fullscreen mode Exit fullscreen mode

nextMove takes what is on the table, what they asked for, the most we may agree for this creator, how many counters we have already made and whether the last one was final. It returns one of those four. It is a total function: there is no state in which it has nothing to say.

The first line of the ladder is the one I like most:

// Asking less than what's on the table is a yes: the fee offered stands.
if (asked <= offered) return { type: "accept", fee: offered };
Enter fullscreen mode Exit fullscreen mode

A creator who writes back "honestly £200 would be plenty for me" after being offered £250 gets £250. The code cannot save the brand money there, and that is deliberate. The email instructions carry the same rule in words: when the agreed fee is more than their ask, say we are glad to pay it as offered. A machine that negotiates downward against someone who undersold themselves would be a machine nobody should let near their brand.

Above that, the shape is: one offer in between, then their number if it still fits; or if their ask is past the limit, the limit itself as a clearly final offer; or just over after that, the brand decides; or far over, a polite no. Every branch either ends the negotiation or spends one of a small fixed number of counters, so there is a hard bound on rounds.

export const MAX_COUNTERS = 2;
Enter fullscreen mode Exit fullscreen mode

I am not publishing the three ratios that set where those boundaries sit, or the multiplier behind the price check, for an obvious reason: a creator who knows exactly what share of their ask the first counter is, and how far over the limit still gets an offer, can read the ladder backwards and pick their opening number accordingly. The shape is the interesting engineering. The constants are just a rate card.

Rounding that can only go down

Nobody offers £318.40 for a video. Amounts are snapped to something a person would say:

// Amounts as people offer them: 275, 1,250, 25,500. Rounded down, so a limit is never passed.
export function roundDown(n: number): number {
  if (!Number.isFinite(n) || n <= 0) return 0;
  const step = Math.max(1, 10 ** (Math.floor(Math.log10(n)) - 1) / 2);
  return Math.floor(n / step) * step;
}
Enter fullscreen mode Exit fullscreen mode

The step is half of one order of magnitude below the number, so it grows with the amount: 279 becomes 275, 1,234 becomes 1,200, 25,640 becomes 25,500, and 73 stays 73. The important word is down. Rounding a limit to the nearest nice number would sometimes round it up, and then a brand that said "at most £500" gets an email agreeing £510. Math.floor is the whole guarantee.

The limit is the lowest of three things, and null is not infinity

export function feeLimit(o: {
  maxFee: number | null | undefined;
  budgetLeft: number | null;
  fair: number | null;
  priceCheck: boolean;
  offered: number;
}): number | null {
  if (!o.maxFee) return null;
  const caps = [o.maxFee, o.budgetLeft ?? Infinity, o.priceCheck && o.fair ? roundDown(o.fair * PRICE_CHECK) : Infinity];
  return Math.max(o.offered, Math.min(...caps));
}
Enter fullscreen mode Exit fullscreen mode

Three caps: the most this brand will pay a creator of this size, what is left of the campaign budget, and with the price check on, a multiple of what this creator's audience is actually worth. The last one is there because the first two are static and an audience is not: a creator with 3,000 regular viewers asking a mid tier rate should not get it just because the brand's ceiling for that size is high.

Math.max(o.offered, ...) means the limit is never below what we already put in writing. A budget that has run out mid campaign cannot retract an offer that is already in a creator's inbox.

And the early return is the part that needs saying out loud: when a brand has not set a most for a creator's size, the limit is null, and null means ask the brand, not pay anything. A size with no ceiling produces a needs_you with a sentence naming it. I have written before about a plan feature where null meant unlimited and the free plan accidentally got more of it than the paid one. Once was enough.

The budget side needs a query rather than a constant, because what is left depends on every other conversation in the campaign:

const fee = o.deal?.agreed ?? o.fee;
Enter fullscreen mode Exit fullscreen mode

Each introduced thread counts at its agreed fee, or at whatever it was last offered if nothing was agreed yet, converted into the campaign's currency. The current thread is excluded by id so a creator never competes with themselves, and a thread whose introduction was taken back is not handed_off any more, so it stops counting on its own.

Where the state lives, and the migration we did not write

A negotiation has state: what they asked, how they wrote it, how many counters we made, whether the last was final, what the limit was when we last decided, what their audience is worth, what they agreed to. That is nine fields.

It is not a table. It is not nine columns. It is a deal object inside the offer JSON column that every thread already had, because the offer is what the deal modifies and the two are always read and written together. Zero migrations, and a thread from before the feature reads as a thread with no deal, which is correct.

const before: Deal = offer.deal ?? {
  firstFee: offer.fee, asked: null, askedText: null, counters: 0,
  final: false, limit: null, fair: null, agreed: null, waiting: null,
};
Enter fullscreen mode Exit fullscreen mode

firstFee is kept because the fee on the offer gets overwritten as the negotiation moves, and the brand still wants to see where it started.

What the model is given

Once the move exists, an email has to say it. The input the writer gets is a list, and you can read everything in it:

Move: counter
Brand: Fernway
Creator's channel: Lift With Lena
What we ask for: a 60 to 90 second mention in an upcoming video
Their ask: £350
Fee to offer: £280
Reason: based on their typical views, about 12K a video
How it's paid: half on publication, half 30 days later
Brand contact for the introduction: Jamie
Enter fullscreen mode Exit fullscreen mode

There is no limit in there. No budget, no remaining budget, no maximum for the size, no other creator, no fee anyone else agreed. It is impossible for the model to leak a limit into an email because the limit was never in the request, and the instructions add the belt to that braces: never mention a budget, a limit, other creators or what anyone else is paid. There is a test asserting that sentence is still in the instructions, which sounds silly until someone rewrites the prompt.

The reply then goes through the same guard every automated answer goes through. Every money amount and percentage in the draft is extracted and compared with the amounts in the input:

// Amounts in an answer that appear nowhere in what the AI was given: made
// up, so the answer isn't sent.
export function unknownAmounts(answer: string, input: string): string[]
Enter fullscreen mode Exit fullscreen mode

It handles £1,200, $1.5k, 250 GBP and 20 per cent, and keeps percentages apart from money so a 10% commission and a £10 fee are not the same fact. Echoing the creator's own rate is fine, because their rate is in the input. Inventing a shipping cost or a second instalment is not. A draft with an invented amount is dropped and the plain version goes instead:

export function fallbackFeeReply(move: "counter" | "final" | "accept", fee: string, name: string | null, contact: string): string {
  const hi = name ? `Hi ${name}, thanks` : "Thanks";
  if (move === "accept") return `${hi}, ${fee} works for us. We've copied in ${contact} to confirm the details and send the brief.`;
  if (move === "final") return `${hi} for getting back to us. ${fee} is the most we can do for this one. Would that work for you?`;
  return `${hi} for coming back with your rate. That's more than we can do for this campaign, but we could offer ${fee}. Would that work for you?`;
}
Enter fullscreen mode Exit fullscreen mode

Those three sentences are flat, and they are true whatever the creator wrote, which is the only property that matters when the model is down, over its tokens, or has just quoted a number from its imagination. The negotiation still works with no model at all. It just reads like a form.

The public side

The promise this code keeps is written down where customers and creators can both read it: "A fee Nakodo agrees for you is never more than the limits you set, and is agreed in principle" in the terms, and "to agree a fee within limits the brand set" in the privacy notice, which is the notice creators get pointed at, not brands.

And if you want to see what the same arithmetic looks like with nothing hidden, the sponsorship cost calculator runs in your browser with no backend, and the guide to what to pay a YouTuber explains the ranges. The calculator publishes a range and refuses to publish a going rate, for the same reason this post publishes the ladder and not the ratios.

Top comments (0)