{"slug": "the-model-reads-the-brand-s-website-and-never-picks-the-number-so-the-same-gets", "title": "The model reads the brand's website and never picks the number, so the same creator always gets the same fee", "summary": "Nakodo, a tool that generates brand outreach emails to creators, deliberately keeps fee calculation out of its LLM, using the model only to classify a brand's website into three enums (buyer type, price level, company size) that collapse into a single clamped multiplier. All downstream pricing is a pure, deterministic function so the same brand and creator always receive the same fee and every figure can be explained, with amounts snapped to human-friendly steps on a log scale.", "body_md": "[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.\n\nThe 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:\n\n```\n// Fees are worked out here rather than by the AI, so the same brand and\n// creator always get the same fee and every number can be explained: the AI\n// reads the brand's website (how much its customers are worth, its `value`),\n// and a rate card turns that and each creator's audience into an amount.\n// Pure, so the proposal editor can use it too.\n```\n\nThree requirements hide in that sentence, and none of them is about accuracy.\n\n**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.\n\n**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.\n\n**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.\n\nThe model does the thing it is good at, which is reading prose and returning a judgement from a closed set:\n\n``` js\nexport const BUYERS = [\"consumers\", \"both\", \"businesses\"] as const;\nexport const PRICE_LEVELS = [\"low\", \"mid\", \"high\", \"enterprise\"] as const;\nexport const COMPANY_SIZES = [\"solo\", \"small\", \"medium\", \"large\"] as const;\n\nexport type BrandRead = {\n  buyer: (typeof BUYERS)[number];\n  priceLevel: (typeof PRICE_LEVELS)[number];\n  companySize: (typeof COMPANY_SIZES)[number];\n};\n```\n\nThree 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.\n\nThose collapse into one number:\n\n``` js\nexport function brandValue(b: BrandRead): number {\n  const v = BUYER_FACTOR[b.buyer] * PRICE_FACTOR[b.priceLevel] * SIZE_FACTOR[b.companySize];\n  return Math.round(clamp(v, 0.5, 3) * 100) / 100;\n}\n```\n\nA 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`.\n\nThat 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.\n\nMultiply a follower count by a rate and you get `211.68`. Nobody quotes `211.68`. People quote 200, 250, 175, 1,200.\n\n```\n// Amounts people actually charge: 50, 75, 120, 150, 175, 200, 250, 300 and so\n// on, the nearest one on a log scale. Works the same in yen or forints.\nconst 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];\n\nexport function niceAmount(n: number): number {\n  if (!Number.isFinite(n) || n <= 0) return 0;\n  const magnitude = 10 ** Math.floor(Math.log10(n));\n  const x = n / magnitude;\n  let best = NICE_STEPS[0];\n  for (const s of NICE_STEPS) if (Math.abs(Math.log(x / s)) < Math.abs(Math.log(x / best))) best = s;\n  return Math.max(1, Math.round(best * magnitude));\n}\n```\n\nPaste that into Node and try it. It is self-contained:\n\n``` php\n37      -> 35\n62      -> 60\n118     -> 120\n164     -> 175\n183     -> 175\n227     -> 250\n460     -> 450\n1234    -> 1200\n2750    -> 3000\n18400   -> 17500\n164000  -> 175000\n211.68  -> 200\n```\n\nTwo details make it work in every currency rather than just in pounds.\n\nThe 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.\n\nThe 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.\n\nIn 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.\n\nThere is also a floor, and it is a product decision rather than an arithmetic one:\n\n```\n// No fee from an audience goes below this (pounds): less isn't worth a\n// creator's afternoon, so it would read as an insult rather than an offer.\nconst FLOOR_GBP = 50;\n\nconst floor = niceAmount(FLOOR_GBP * CURRENCIES[r.currency].rate);\nreturn Math.max(floor, niceAmount(units * RATES[a.platform][r.format] * r.value * LEVEL_FACTOR[r.level] * rate));\n```\n\nA 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.\n\nNote 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.\n\nCreators 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.\n\nWhen it is from their own audience, the result is clamped into the tier's own range:\n\n``` js\nconst range = tierFees(tier, terms, a.platform); // priced at the tier's endpoints\nconst market = marketFee({ ...a, platform: creatorPlatform(a.platform) }, { ...terms, format: tier.format });\nreturn { tier, amount: market === null ? range.low : clamp(market, range.low, range.high) };\n```\n\nSo 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.\n\nAnd 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:\n\n```\nexport function middleFee(t: Pick<Tier, \"from\" | \"to\" | \"format\">, terms, platform: Platform): number {\n  const followers = Math.sqrt(Math.max(1, t.from) * Math.max(1, t.to));\n  return marketFee({ platform: creatorPlatform(platform), followers, views: null }, { ...terms, format: t.format }) ?? 0;\n}\n```\n\nThe 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.\n\nThe 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:\n\n```\n// A long-form YouTube video's typical views as a share of subscribers, for\n// channels whose views we don't have.\nconst VIEWS_PER_SUBSCRIBER = 0.15;\n```\n\nOne named constant with a comment beats the same 0.15 appearing in four expressions.\n\nFees are quoted in the brand's currency, with rates held in code:\n\n```\n// Rates are from the ECB reference rates of 2026-10-02, crossed through the\n// euro. They're fixed, not live, because they set the charge. Refresh them when\n// one moves more than about 3%.\n```\n\nLive 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.)\n\nConverting a set of terms re-rounds everything:\n\n```\nexport function convertTerms(terms: CreatorTerms, to: Currency): CreatorTerms {\n  if (to === terms.currency) return terms;\n  const factor = CURRENCIES[to].rate / CURRENCIES[terms.currency].rate;\n  return { ...terms, currency: to, tiers: terms.tiers.map((t) => (t.fee ? { ...t, fee: { ...t.fee, amount: niceAmount(t.fee.amount * factor) } } : t)) };\n}\n```\n\n£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.\n\nFormatting is one `Intl` call with the currency's own locale, and no fractional units anywhere:\n\n```\nexport function formatFee(amount: number, currency: Currency = BASE_CURRENCY): string {\n  const { code, locale } = CURRENCIES[currency];\n  return new Intl.NumberFormat(locale, { style: \"currency\", currency: code, maximumFractionDigits: 0, minimumFractionDigits: 0 }).format(amount);\n}\n```\n\nTerms 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:\n\n``` js\nexport function cleanTerms(terms: CreatorTerms): CreatorTerms | null {\n  const whole = (n: number) => (Number.isFinite(n) ? Math.round(n) : 0);\n  const tiers = [...terms.tiers]\n    .map((t) => ({ ...t, from: Math.max(0, whole(t.from)), to: Math.max(0, whole(t.to)), ask: t.ask.trim(), /* ... */ }))\n    .sort((a, b) => a.from - b.from)\n    .filter((t, i, all) => i === 0 || t.size !== all[i - 1].size);\n  const ok = tiers.length > 0 && tiers.every((t) =>\n    t.to > t.from &&\n    (!t.fee || t.fee.mode === \"audience\" || t.fee.amount > 0) &&\n    (t.commission === null || (t.commission >= 1 && t.commission <= 50)));\n  return ok ? { ...terms, tiers } : null;\n}\n```\n\nIt 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.\n\nOur 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.\n\nInternally, 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.\n\nThose 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.\n\nA 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.", "url": "https://wpnews.pro/news/the-model-reads-the-brand-s-website-and-never-picks-the-number-so-the-same-gets", "canonical_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_at": "2026-10-06 10:15:58+00:00", "updated_at": "2026-10-06 10:18:12.449326+00:00", "lang": "en", "topics": ["ai-tools", "large-language-models", "ai-products"], "entities": ["Nakodo"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/the-model-reads-the-brand-s-website-and-never-picks-the-number-so-the-same-gets", "markdown": "https://wpnews.pro/news/the-model-reads-the-brand-s-website-and-never-picks-the-number-so-the-same-gets.md", "text": "https://wpnews.pro/news/the-model-reads-the-brand-s-website-and-never-picks-the-number-so-the-same-gets.txt", "jsonld": "https://wpnews.pro/news/the-model-reads-the-brand-s-website-and-never-picks-the-number-so-the-same-gets.jsonld"}}