# The model reads the brand's website and never picks the number, so the same creator always gets the same fee

> Source: <https://dev.to/daniel_pertu/the-model-reads-the-brands-website-and-never-picks-the-number-so-the-same-creator-always-gets-the-2clk>
> Published: 2026-10-06 10:15:58+00:00

[Nakodo](https://nakodo.app/how-it-works) writes the outreach email a brand sends a creator, and the newest part of that is the proposal: what the creator is asked to make, what they get to keep, and what they are paid. The fee goes in an email with the brand's name on it.

The temptation with an LLM in the stack is obvious. It has read the brand's site, it knows the creator's follower count, so ask it for a fee. We do not, and the reason is in a comment at the top of the file:

```
// Fees are worked out here rather than by the AI, so the same brand and
// creator always get the same fee and every number can be explained: the AI
// reads the brand's website (how much its customers are worth, its `value`),
// and a rate card turns that and each creator's audience into an amount.
// Pure, so the proposal editor can use it too.
```

Three requirements hide in that sentence, and none of them is about accuracy.

**The same inputs must give the same output.** A creator asks what you pay. The brand opens the proposal editor, sees £180, and sends it. Two weeks later the same creator is re-contacted for a second campaign and the model, with a different temperature draw, says £240. Now the brand has a conversation it cannot explain, and the creator has evidence that the number is made up. A model call is a sample from a distribution. Money has to be a function.

**Every number must be explainable.** When a brand asks "why 180?", the answer has to be a chain: this creator's typical views, times the rate for this format on this platform, times what your customers are worth, times the level you chose, floored and rounded. Each link is inspectable. "The model thought so" is not an answer you can give somebody who is about to spend their own money.

**It has to run in the browser.** The proposal editor shows fees updating as you drag a tier boundary or switch from market rate to a fixed amount. That is a pure function or it is a network round trip per keystroke.

The model does the thing it is good at, which is reading prose and returning a judgement from a closed set:

``` js
export const BUYERS = ["consumers", "both", "businesses"] as const;
export const PRICE_LEVELS = ["low", "mid", "high", "enterprise"] as const;
export const COMPANY_SIZES = ["solo", "small", "medium", "large"] as const;

export type BrandRead = {
  buyer: (typeof BUYERS)[number];
  priceLevel: (typeof PRICE_LEVELS)[number];
  companySize: (typeof COMPANY_SIZES)[number];
};
```

Three enums, three to four options each: 48 possible readings of a brand. It is answering "who buys this, is it cheap or expensive, how big is this company", which is exactly what a person gets from thirty seconds on a homepage.

Those collapse into one number:

``` js
export function brandValue(b: BrandRead): number {
  const v = BUYER_FACTOR[b.buyer] * PRICE_FACTOR[b.priceLevel] * SIZE_FACTOR[b.companySize];
  return Math.round(clamp(v, 0.5, 3) * 100) / 100;
}
```

A single multiplier, clamped to between half and triple, rounded to two decimals. The clamp is there because three independent factors multiplied together have a product range wider than anybody's actual willingness to pay, and the rounding is so the value can be stored, shown, and edited by a human without ever rendering as `1.3799999999999999`.

That multiplier is what crosses the boundary between the probabilistic part of the system and the arithmetic part. Everything downstream of it is a pure function of numbers, and the whole of it can be re-run against a hand-edited `value` when a brand disagrees with the model's read.

Multiply a follower count by a rate and you get `211.68`. Nobody quotes `211.68`. People quote 200, 250, 175, 1,200.

```
// Amounts people actually charge: 50, 75, 120, 150, 175, 200, 250, 300 and so
// on, the nearest one on a log scale. Works the same in yen or forints.
const NICE_STEPS = [1, 1.2, 1.5, 1.75, 2, 2.5, 3, 3.5, 4, 4.5, 5, 6, 7, 7.5, 8, 9, 10];

export function niceAmount(n: number): number {
  if (!Number.isFinite(n) || n <= 0) return 0;
  const magnitude = 10 ** Math.floor(Math.log10(n));
  const x = n / magnitude;
  let best = NICE_STEPS[0];
  for (const s of NICE_STEPS) if (Math.abs(Math.log(x / s)) < Math.abs(Math.log(x / best))) best = s;
  return Math.max(1, Math.round(best * magnitude));
}
```

Paste that into Node and try it. It is self-contained:

``` php
37      -> 35
62      -> 60
118     -> 120
164     -> 175
183     -> 175
227     -> 250
460     -> 450
1234    -> 1200
2750    -> 3000
18400   -> 17500
164000  -> 175000
211.68  -> 200
```

Two details make it work in every currency rather than just in pounds.

The mantissa is extracted with `10 ** Math.floor(Math.log10(n))`, so the step list is scale-free. The same seventeen steps give you 35 and 175 and 175,000. A fixed list of nice numbers would have needed a different list per currency, and 20,000 forints is as round a number as 50 pounds.

The comparison is `Math.abs(Math.log(x / s))`, which is distance in proportion rather than in units. That matters because the step list is unevenly spaced on purpose: 0.2 apart at the bottom of a decade and 1.0 apart at the top. Measuring in units would make the wide gaps near the top win more often than they should, and would resolve a lot of near-ties by accident.

In proportion, 183 sits 4.6% above 175 and 8.5% below 200, so it rounds down to 175. A 5% difference in a fee reads the same whether the fee is 20 or 20,000, which is exactly the property you want from a rounder that has to work at both ends.

There is also a floor, and it is a product decision rather than an arithmetic one:

```
// No fee from an audience goes below this (pounds): less isn't worth a
// creator's afternoon, so it would read as an insult rather than an offer.
const FLOOR_GBP = 50;

const floor = niceAmount(FLOOR_GBP * CURRENCIES[r.currency].rate);
return Math.max(floor, niceAmount(units * RATES[a.platform][r.format] * r.value * LEVEL_FACTOR[r.level] * rate));
```

A nano creator with 1,200 followers computes out to single digits. An email offering somebody eleven pounds to make a video is worse for the brand than an email offering nothing, so the arithmetic is allowed to produce a number the rate card did not.

Note that the floor is itself passed through `niceAmount` after conversion. 50 pounds in Hungarian forints is 21,708, and the floor of a price range should look like a decision, so it becomes 20,000.

Creators are grouped by size (nano, micro, mid, macro, mega), each tier gets its own ask, offer and fee mode, and a tier's fee can either be worked out from each creator's own audience or be a flat amount for everyone in the tier.

When it is from their own audience, the result is clamped into the tier's own range:

``` js
const range = tierFees(tier, terms, a.platform); // priced at the tier's endpoints
const market = marketFee({ ...a, platform: creatorPlatform(a.platform) }, { ...terms, format: tier.format });
return { tier, amount: market === null ? range.low : clamp(market, range.low, range.high) };
```

So what a brand sees in the editor ("micro creators: £120 to £400") is a promise, not a summary. No individual creator can be quoted outside the band the brand approved, even if their view count is freakish.

And when the brand switches a tier to a flat fee, the starting suggestion is the fee for a creator in the middle of the tier, where "middle" is the geometric mean:

```
export function middleFee(t: Pick<Tier, "from" | "to" | "format">, terms, platform: Platform): number {
  const followers = Math.sqrt(Math.max(1, t.from) * Math.max(1, t.to));
  return marketFee({ platform: creatorPlatform(platform), followers, views: null }, { ...terms, format: t.format }) ?? 0;
}
```

The micro tier runs from 10,000 to 100,000 followers. The arithmetic middle is 55,000, which is not a typical micro creator, it is a large one: follower counts are roughly log-distributed, so most of the tier's population sits in its lower half. The geometric middle is 31,623, and a fee derived from that is the fee most creators in the tier will actually be offered. Any time a range spans more than one order of magnitude, `sqrt(a * b)` is the midpoint you meant.

The same reasoning shows up in how audiences are measured at all: YouTube is priced per thousand *views*, because that is how sponsorships there are negotiated, while Instagram and TikTok are priced per thousand *followers*, because their view counts are often hidden and swing by an order of magnitude between posts. When a YouTube channel hides its subscriber count, there is one documented constant to bridge the gap:

```
// A long-form YouTube video's typical views as a share of subscribers, for
// channels whose views we don't have.
const VIEWS_PER_SUBSCRIBER = 0.15;
```

One named constant with a comment beats the same 0.15 appearing in four expressions.

Fees are quoted in the brand's currency, with rates held in code:

```
// Rates are from the ECB reference rates of 2026-10-02, crossed through the
// euro. They're fixed, not live, because they set the charge. Refresh them when
// one moves more than about 3%.
```

Live rates would make an offer that moves while the brand is reading it, and a fee quoted in an email at 09:00 that is a different number in the app at 11:00. A fixed rate with a date and a stated refresh rule is both more honest and easier to reason about. (The same table prices our own plans, and the figure shown is the figure charged.)

Converting a set of terms re-rounds everything:

```
export function convertTerms(terms: CreatorTerms, to: Currency): CreatorTerms {
  if (to === terms.currency) return terms;
  const factor = CURRENCIES[to].rate / CURRENCIES[terms.currency].rate;
  return { ...terms, currency: to, tiers: terms.tiers.map((t) => (t.fee ? { ...t, fee: { ...t.fee, amount: niceAmount(t.fee.amount * factor) } } : t)) };
}
```

£180 becomes €200, not €211.68. In yen it becomes ¥40,000. A converted price that keeps the decimals of the exchange rate tells the recipient the number was computed from somewhere else, which is exactly the impression a brand does not want to give a creator.

Formatting is one `Intl` call with the currency's own locale, and no fractional units anywhere:

```
export function formatFee(amount: number, currency: Currency = BASE_CURRENCY): string {
  const { code, locale } = CURRENCIES[currency];
  return new Intl.NumberFormat(locale, { style: "currency", currency: code, maximumFractionDigits: 0, minimumFractionDigits: 0 }).format(amount);
}
```

Terms are hand-editable, which means they can be made invalid: a tier whose `to` is below its `from`, a 0% commission, a fee of 12.5, two tiers claiming the same size. Rather than scatter those checks through the UI, there is a single function that either returns usable terms or returns null:

``` js
export function cleanTerms(terms: CreatorTerms): CreatorTerms | null {
  const whole = (n: number) => (Number.isFinite(n) ? Math.round(n) : 0);
  const tiers = [...terms.tiers]
    .map((t) => ({ ...t, from: Math.max(0, whole(t.from)), to: Math.max(0, whole(t.to)), ask: t.ask.trim(), /* ... */ }))
    .sort((a, b) => a.from - b.from)
    .filter((t, i, all) => i === 0 || t.size !== all[i - 1].size);
  const ok = tiers.length > 0 && tiers.every((t) =>
    t.to > t.from &&
    (!t.fee || t.fee.mode === "audience" || t.fee.amount > 0) &&
    (t.commission === null || (t.commission >= 1 && t.commission <= 50)));
  return ok ? { ...terms, tiers } : null;
}
```

It normalises what it can (rounding, trimming, sorting, de-duplicating sizes) and refuses what it cannot. Everything downstream takes `CreatorTerms` that came out of here, so the code that composes an email has no opinions about whether 0% commission is meaningful. That is the "parse, do not validate" shape: the return type is the proof.

Our public [sponsorship cost calculator](https://nakodo.app/tools/sponsorship-cost-calculator) deliberately publishes no going rate. It will turn a CPM into a fee, turn a quote into an effective CPM, and work out your break-even, but it refuses to tell you what a creator costs, and the page says why: the price depends on the niche, the format, the usage rights and the creator, and a single number pretending otherwise would be wrong for almost everybody. The [guide on what to pay YouTubers](https://nakodo.app/guides/how-much-to-pay-youtubers) takes the same line.

Internally, we need a number anyway, because a proposal with a blank fee is not a proposal. So there is a rate card, in one exported constant, with a comment recording which published ranges it came from and an instruction to tune it there.

Those two positions are consistent, and the difference is the audience. A default we use to start a negotiation can be wrong and corrected, by the brand, in an editor, before anything is sent. A rate published on a marketing page becomes an anchor in thousands of negotiations we will never see, and we would have no way to be accountable for it.

A private default and a public refusal are not hypocrisy. They are what it looks like when you take seriously the difference between a suggestion you can take back and a number you have put on the internet.
