[{"data":1,"prerenderedAt":1063},["ShallowReactive",2],{"profile":3,"projects:page:\u002Fprojects\u002Fclairwire\u002Farchitecture":22,"projects:docs:\u002Fprojects\u002Fclairwire":1030,"structured-data:current-stack":1049},{"id":4,"alias":5,"current":6,"email":10,"extension":11,"headline":12,"links":13,"location":17,"meta":18,"name":19,"stem":20,"__hash__":21},"profile\u002Fprofile.yml","readonly",{"role":7,"company":8,"companyUrl":9},"Fullstack Engineer — Tech Lead","DataCose","https:\u002F\u002Fdatacose.com","redonemini18@gmail.com","yml","Fullstack Engineer",{"github":14,"linkedin":15,"twitch":16},"https:\u002F\u002Fgithub.com\u002FRedoni18","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fredon-emini\u002F","https:\u002F\u002Fwww.twitch.tv\u002Fr3adonly","Pristina, Kosovo",{},"Redon Emini","profile","Fbb1IAp4nuUOOtgXdwpzcqSmG0CUKtZ4D0ifPqdK5QU",{"id":23,"title":24,"body":25,"description":1015,"extension":1016,"featured":1017,"kind":1018,"links":1019,"meta":1020,"navigation":1021,"order":1023,"path":1024,"period":1025,"preview":1025,"seo":1026,"stack":1027,"stem":1028,"year":1025,"__hash__":1029},"projects\u002Fprojects\u002Fclairwire\u002F1.architecture.md","One contract, many sources",{"type":26,"value":27,"toc":1008},"minimark",[28,32,35,38,43,51,234,246,253,257,268,392,399,406,410,413,429,440,498,509,513,516,570,590,593,596,616,620,627,803,810,813,993,996,1004],[29,30,31],"p",{},"A monitoring product is only as good as the number of places it can watch. Clairwire pulls from news search, RSS and Atom feeds, ordinary web pages, X, Bluesky, YouTube, Reddit, Hacker News and podcasts, and each of those speaks a completely different language. A post on X has likes and a handle. A YouTube video has views and a channel. An RSS item has neither, and sometimes doesn't even have an author.",[29,33,34],{},"The easy way to support all of them is to let each platform's quirks flow through the system: a YouTube branch in the analysis code, an X branch in the alert rules, a special case or two in the dashboard. That's quick for the first couple of sources. By the fifth, adding a platform means touching every stage of the pipeline, and every stage has to be retested against all the others.",[29,36,37],{},"Clairwire goes the other way. Here are the four decisions that keep it manageable.",[39,40,42],"h2",{"id":41},"_1-every-source-produces-the-same-shape","1. Every source produces the same shape",[29,44,45,46,50],{},"The shared package defines a single type, ",[47,48,49],"code",{},"NormalizedMention",", and every source has to produce it. It's the most important file in the codebase:",[52,53,59],"pre",{"className":54,"code":55,"filename":56,"language":57,"meta":58,"style":58},"language-ts shiki shiki-themes vitesse-light vitesse-dark","export interface NormalizedMention {\n  externalId: string    \u002F\u002F stable id from the source, used for dedup\n  sourceType: SourceType\n  sourceName: string    \u002F\u002F \"TechCrunch\", \"@handle\", …\n  url: string\n  title?: string\n  content: string\n  author?: MentionAuthor\n  publishedAt: string   \u002F\u002F ISO 8601, original publication\n  discoveredAt: string  \u002F\u002F ISO 8601, when Clairwire first saw it\n  metrics?: EngagementMetrics   \u002F\u002F likes, shares, comments, views\n  raw?: unknown         \u002F\u002F the original payload, kept for audit and re-processing\n}\n","packages\u002Fshared\u002Fsrc\u002Fmention.ts","ts","",[47,60,61,82,99,110,123,134,147,157,170,183,196,212,228],{"__ignoreMap":58},[62,63,66,70,74,78],"span",{"class":64,"line":65},"line",1,[62,67,69],{"class":68},"sTPum","export",[62,71,73],{"class":72},"s5TCs"," interface",[62,75,77],{"class":76},"s_NWU"," NormalizedMention",[62,79,81],{"class":80},"si6no"," {\n",[62,83,85,89,92,95],{"class":64,"line":84},2,[62,86,88],{"class":87},"s9nN2","  externalId",[62,90,91],{"class":80},": ",[62,93,94],{"class":76},"string",[62,96,98],{"class":97},"snYqZ","    \u002F\u002F stable id from the source, used for dedup\n",[62,100,102,105,107],{"class":64,"line":101},3,[62,103,104],{"class":87},"  sourceType",[62,106,91],{"class":80},[62,108,109],{"class":76},"SourceType\n",[62,111,113,116,118,120],{"class":64,"line":112},4,[62,114,115],{"class":87},"  sourceName",[62,117,91],{"class":80},[62,119,94],{"class":76},[62,121,122],{"class":97},"    \u002F\u002F \"TechCrunch\", \"@handle\", …\n",[62,124,126,129,131],{"class":64,"line":125},5,[62,127,128],{"class":87},"  url",[62,130,91],{"class":80},[62,132,133],{"class":76},"string\n",[62,135,137,140,143,145],{"class":64,"line":136},6,[62,138,139],{"class":87},"  title",[62,141,142],{"class":72},"?",[62,144,91],{"class":80},[62,146,133],{"class":76},[62,148,150,153,155],{"class":64,"line":149},7,[62,151,152],{"class":87},"  content",[62,154,91],{"class":80},[62,156,133],{"class":76},[62,158,160,163,165,167],{"class":64,"line":159},8,[62,161,162],{"class":87},"  author",[62,164,142],{"class":72},[62,166,91],{"class":80},[62,168,169],{"class":76},"MentionAuthor\n",[62,171,173,176,178,180],{"class":64,"line":172},9,[62,174,175],{"class":87},"  publishedAt",[62,177,91],{"class":80},[62,179,94],{"class":76},[62,181,182],{"class":97},"   \u002F\u002F ISO 8601, original publication\n",[62,184,186,189,191,193],{"class":64,"line":185},10,[62,187,188],{"class":87},"  discoveredAt",[62,190,91],{"class":80},[62,192,94],{"class":76},[62,194,195],{"class":97},"  \u002F\u002F ISO 8601, when Clairwire first saw it\n",[62,197,199,202,204,206,209],{"class":64,"line":198},11,[62,200,201],{"class":87},"  metrics",[62,203,142],{"class":72},[62,205,91],{"class":80},[62,207,208],{"class":76},"EngagementMetrics",[62,210,211],{"class":97},"   \u002F\u002F likes, shares, comments, views\n",[62,213,215,218,220,222,225],{"class":64,"line":214},12,[62,216,217],{"class":87},"  raw",[62,219,142],{"class":72},[62,221,91],{"class":80},[62,223,224],{"class":76},"unknown",[62,226,227],{"class":97},"         \u002F\u002F the original payload, kept for audit and re-processing\n",[62,229,231],{"class":64,"line":230},13,[62,232,233],{"class":80},"}\n",[29,235,236,237,241,242,245],{},"Fields that only some platforms have, like author details and engagement numbers, are optional. Everything a later stage ",[238,239,240],"em",{},"needs"," is required. And ",[47,243,244],{},"raw"," keeps the original payload, so if the analysis changes later, old mentions can be re-processed from exactly what the source sent.",[29,247,248,249,252],{},"Deduplication shows why this pays off. Every mention has an ",[47,250,251],{},"externalId"," (a post ID, a video ID, a hash of an article's URL), so \"have we seen this before?\" is one lookup that works the same way for every source. Nobody had to write dedup logic for YouTube specifically.",[39,254,256],{"id":255},"_2-adapters-are-the-only-place-a-platform-exists","2. Adapters are the only place a platform exists",[29,258,259,260,264,265,267],{},"Each source is an ",[261,262,263],"strong",{},"adapter",": a class that knows how to talk to one platform and translate what it returns into ",[47,266,49],{},"s. It's the adapter pattern straight out of the textbooks. Here's the interface every adapter implements, trimmed to the essentials:",[52,269,272],{"className":54,"code":270,"filename":271,"language":57,"meta":58,"style":58},"export interface SourceAdapter {\n  readonly sourceType: SourceType\n  \u002F** Label, description and config fields for the \"add source\" form. *\u002F\n  readonly meta: SourceAdapterMeta\n  \u002F** False when, for example, this platform's API key isn't configured. *\u002F\n  isAvailable?(): boolean\n  \u002F** Fetch everything published since the last poll. *\u002F\n  fetchSince(config: SourceAdapterConfig, since: Date): Promise\u003CNormalizedMention[] | SourceFetchResult>\n}\n","apps\u002Fapi\u002Fsrc\u002Fmodules\u002Fsources\u002Fsource-adapter.interface.ts",[47,273,274,285,297,302,314,319,333,338,388],{"__ignoreMap":58},[62,275,276,278,280,283],{"class":64,"line":65},[62,277,69],{"class":68},[62,279,73],{"class":72},[62,281,282],{"class":76}," SourceAdapter",[62,284,81],{"class":80},[62,286,287,290,293,295],{"class":64,"line":84},[62,288,289],{"class":72},"  readonly",[62,291,292],{"class":87}," sourceType",[62,294,91],{"class":80},[62,296,109],{"class":76},[62,298,299],{"class":64,"line":101},[62,300,301],{"class":97},"  \u002F** Label, description and config fields for the \"add source\" form. *\u002F\n",[62,303,304,306,309,311],{"class":64,"line":112},[62,305,289],{"class":72},[62,307,308],{"class":87}," meta",[62,310,91],{"class":80},[62,312,313],{"class":76},"SourceAdapterMeta\n",[62,315,316],{"class":64,"line":125},[62,317,318],{"class":97},"  \u002F** False when, for example, this platform's API key isn't configured. *\u002F\n",[62,320,321,325,327,330],{"class":64,"line":136},[62,322,324],{"class":323},"s_xSY","  isAvailable",[62,326,142],{"class":72},[62,328,329],{"class":80},"():",[62,331,332],{"class":76}," boolean\n",[62,334,335],{"class":64,"line":149},[62,336,337],{"class":97},"  \u002F** Fetch everything published since the last poll. *\u002F\n",[62,339,340,343,346,349,351,354,357,360,362,365,368,371,374,376,379,382,385],{"class":64,"line":159},[62,341,342],{"class":323},"  fetchSince",[62,344,345],{"class":80},"(",[62,347,348],{"class":87},"config",[62,350,91],{"class":80},[62,352,353],{"class":76},"SourceAdapterConfig",[62,355,356],{"class":80},",",[62,358,359],{"class":87}," since",[62,361,91],{"class":80},[62,363,364],{"class":76},"Date",[62,366,367],{"class":80},"):",[62,369,370],{"class":76}," Promise",[62,372,373],{"class":80},"\u003C",[62,375,49],{"class":76},[62,377,378],{"class":80},"[]",[62,380,381],{"class":80}," |",[62,383,384],{"class":76}," SourceFetchResult",[62,386,387],{"class":80},">\n",[62,389,390],{"class":64,"line":172},[62,391,233],{"class":80},[29,393,394,395,398],{},"Adding a platform means writing one class and registering it. The ingestion code calls ",[47,396,397],{},"fetchSince",", gets back mentions in a shape it already understands, and carries on. Adapters don't touch the database either. The ingestion worker hands them the client's name, aliases and keywords, and they hand back data. That makes them easy to test against recorded API responses.",[29,400,401,402,405],{},"Notice that ",[47,403,404],{},"meta"," lives on the adapter itself. The form a user fills in when adding a source (which fields it has, what each one means) is built from it, so there's no second list of sources in the frontend to keep in sync.",[39,407,409],{"id":408},"_3-ask-what-a-source-can-do-not-what-it-is","3. Ask what a source can do, not what it is",[29,411,412],{},"Even with a shared shape, sources really do differ in ways the pipeline has to respect. Two examples:",[414,415,416,423],"ul",{},[417,418,419,422],"li",{},[261,420,421],{},"Some sources are already filtered to the client, and some aren't."," When the X adapter searches for a client's name, everything it returns at least mentions them. A whole-site RSS feed returns every article that site publishes, most of which have nothing to do with the client, so those results need a relevance check before they're stored.",[417,424,425,428],{},[261,426,427],{},"On some platforms, the same text twice means the same post."," If an account on X posts identical text twice in a day, that's a repost for reach, not new coverage, and it should be folded into the first sighting. On YouTube, two videos with the same description are still two different videos.",[29,430,431,432,435,436,439],{},"The tempting fix is an ",[47,433,434],{},"if (mention.sourceType === 'rss')"," somewhere downstream. That's exactly the kind of branch the contract was meant to prevent, and it tends to get copied into three places before anyone notices. Instead, adapters declare ",[261,437,438],{},"capabilities"," in their metadata, and the pipeline reads those:",[52,441,443],{"className":54,"code":442,"filename":271,"language":57,"meta":58,"style":58},"export interface SourceAdapterMeta {\n  \u002F\u002F ...\n  \u002F** Every mention this adapter emits is already about the client. *\u002F\n  clientScoped?: boolean\n  \u002F** Identical text from the same author within a day is the same post. *\u002F\n  foldsRepeats?: boolean\n}\n",[47,444,445,456,461,466,478,483,494],{"__ignoreMap":58},[62,446,447,449,451,454],{"class":64,"line":65},[62,448,69],{"class":68},[62,450,73],{"class":72},[62,452,453],{"class":76}," SourceAdapterMeta",[62,455,81],{"class":80},[62,457,458],{"class":64,"line":84},[62,459,460],{"class":97},"  \u002F\u002F ...\n",[62,462,463],{"class":64,"line":101},[62,464,465],{"class":97},"  \u002F** Every mention this adapter emits is already about the client. *\u002F\n",[62,467,468,471,473,475],{"class":64,"line":112},[62,469,470],{"class":87},"  clientScoped",[62,472,142],{"class":72},[62,474,91],{"class":80},[62,476,477],{"class":76},"boolean\n",[62,479,480],{"class":64,"line":125},[62,481,482],{"class":97},"  \u002F** Identical text from the same author within a day is the same post. *\u002F\n",[62,484,485,488,490,492],{"class":64,"line":136},[62,486,487],{"class":87},"  foldsRepeats",[62,489,142],{"class":72},[62,491,91],{"class":80},[62,493,477],{"class":76},[62,495,496],{"class":64,"line":149},[62,497,233],{"class":80},[29,499,500,501,504,505,508],{},"The ingestion code checks ",[47,502,503],{},"clientScoped",". It never checks ",[47,506,507],{},"sourceType",". When a new platform comes along, its adapter answers these questions once, and every stage downstream does the right thing without being edited. You've probably heard \"program to an interface, not an implementation\". This is the same idea applied to data: describe the behavior you care about, and let the pipeline depend on that description.",[39,510,512],{"id":511},"_4-the-work-happens-in-queues-not-in-requests","4. The work happens in queues, not in requests",[29,514,515],{},"Here's the whole pipeline, from a source being polled to someone getting an email:",[517,518,519],"mermaid",{},[52,520,523],{"className":521,"code":522,"language":517,"meta":58,"style":58},"language-mermaid shiki shiki-themes vitesse-light vitesse-dark","flowchart LR\n  A[Source adapters] --> B[Normalize]\n  B --> C[Dedupe + store]\n  C --> D[AI analysis]\n  D --> E[Alert rules]\n  E --> F[Notify: email \u002F Slack]\n  D --> G[Dashboard]\n  D --> H[Daily brief]\n  H --> F\n",[47,524,525,530,535,540,545,550,555,560,565],{"__ignoreMap":58},[62,526,527],{"class":64,"line":65},[62,528,529],{},"flowchart LR\n",[62,531,532],{"class":64,"line":84},[62,533,534],{},"  A[Source adapters] --> B[Normalize]\n",[62,536,537],{"class":64,"line":101},[62,538,539],{},"  B --> C[Dedupe + store]\n",[62,541,542],{"class":64,"line":112},[62,543,544],{},"  C --> D[AI analysis]\n",[62,546,547],{"class":64,"line":125},[62,548,549],{},"  D --> E[Alert rules]\n",[62,551,552],{"class":64,"line":136},[62,553,554],{},"  E --> F[Notify: email \u002F Slack]\n",[62,556,557],{"class":64,"line":149},[62,558,559],{},"  D --> G[Dashboard]\n",[62,561,562],{"class":64,"line":159},[62,563,564],{},"  D --> H[Daily brief]\n",[62,566,567],{"class":64,"line":172},[62,568,569],{},"  H --> F\n",[29,571,572,573,576,577,576,580,576,583,576,586,589],{},"Each stage has its own BullMQ queue: ",[47,574,575],{},"ingestion",", ",[47,578,579],{},"analysis",[47,581,582],{},"alerts",[47,584,585],{},"notifications",[47,587,588],{},"briefings",". When a stage finishes with a mention, it doesn't call the next stage directly. It adds a job to the next queue and moves on. The ingestion worker, for example, stores the new mentions it found and then queues one analysis job per mention.",[29,591,592],{},"The HTTP API follows the same rule. A request can add a job or read results, but it never does the slow work itself. When someone clicks \"Check now\" on a source, the API queues a poll and responds straight away.",[29,594,595],{},"If you haven't worked with job queues before, here's what this buys you:",[414,597,598,604,610],{},[417,599,600,603],{},[261,601,602],{},"A slow step can't hold up a fast one."," An AI call that takes eight seconds ties up one analysis worker, not someone's page load, and not the polling of the next source.",[417,605,606,609],{},[261,607,608],{},"Failures stay contained, and they can be retried."," If a platform's API is down, that one poll job fails and runs again later. Nothing else notices.",[417,611,612,615],{},[261,613,614],{},"Each stage can be throttled on its own."," A platform that charges per request, or rate-limits hard, can be polled less often without touching anything else. The X adapter can even report when a poll hit its own read limit, and the scheduler uses that to adjust how often that source runs.",[39,617,619],{"id":618},"an-ai-layer-you-can-swap-and-a-mock-you-cant-ship","An AI layer you can swap, and a mock you can't ship",[29,621,622,623,626],{},"Every AI call in the app goes through one interface. Analyzing a mention, writing a daily brief, narrating a report and summarizing text are all methods on ",[47,624,625],{},"AiProvider",", and no vendor SDK is imported anywhere outside the classes that implement it.",[52,628,631],{"className":54,"code":629,"filename":630,"language":57,"meta":58,"style":58},"export interface AiProvider {\n  readonly name: string\n  readonly model: string   \u002F\u002F recorded on every analysis\n\n  analyzeMention(mention: NormalizedMention, client: ClientContext): Promise\u003CMentionAnalysis>\n  generateBriefing(input: BriefingInput): Promise\u003CGeneratedBriefing>\n  generateReport(input: ReportInput): Promise\u003CGeneratedBriefing>\n  summarize(text: string, instructions?: string): Promise\u003Cstring>\n  \u002F\u002F ...\n}\n","apps\u002Fapi\u002Fsrc\u002Fmodules\u002Fai\u002Fai-provider.interface.ts",[47,632,633,644,655,669,675,710,736,760,795,799],{"__ignoreMap":58},[62,634,635,637,639,642],{"class":64,"line":65},[62,636,69],{"class":68},[62,638,73],{"class":72},[62,640,641],{"class":76}," AiProvider",[62,643,81],{"class":80},[62,645,646,648,651,653],{"class":64,"line":84},[62,647,289],{"class":72},[62,649,650],{"class":87}," name",[62,652,91],{"class":80},[62,654,133],{"class":76},[62,656,657,659,662,664,666],{"class":64,"line":101},[62,658,289],{"class":72},[62,660,661],{"class":87}," model",[62,663,91],{"class":80},[62,665,94],{"class":76},[62,667,668],{"class":97},"   \u002F\u002F recorded on every analysis\n",[62,670,671],{"class":64,"line":112},[62,672,674],{"emptyLinePlaceholder":673},true,"\n",[62,676,677,680,682,685,687,689,691,694,696,699,701,703,705,708],{"class":64,"line":125},[62,678,679],{"class":323},"  analyzeMention",[62,681,345],{"class":80},[62,683,684],{"class":87},"mention",[62,686,91],{"class":80},[62,688,49],{"class":76},[62,690,356],{"class":80},[62,692,693],{"class":87}," client",[62,695,91],{"class":80},[62,697,698],{"class":76},"ClientContext",[62,700,367],{"class":80},[62,702,370],{"class":76},[62,704,373],{"class":80},[62,706,707],{"class":76},"MentionAnalysis",[62,709,387],{"class":80},[62,711,712,715,717,720,722,725,727,729,731,734],{"class":64,"line":136},[62,713,714],{"class":323},"  generateBriefing",[62,716,345],{"class":80},[62,718,719],{"class":87},"input",[62,721,91],{"class":80},[62,723,724],{"class":76},"BriefingInput",[62,726,367],{"class":80},[62,728,370],{"class":76},[62,730,373],{"class":80},[62,732,733],{"class":76},"GeneratedBriefing",[62,735,387],{"class":80},[62,737,738,741,743,745,747,750,752,754,756,758],{"class":64,"line":149},[62,739,740],{"class":323},"  generateReport",[62,742,345],{"class":80},[62,744,719],{"class":87},[62,746,91],{"class":80},[62,748,749],{"class":76},"ReportInput",[62,751,367],{"class":80},[62,753,370],{"class":76},[62,755,373],{"class":80},[62,757,733],{"class":76},[62,759,387],{"class":80},[62,761,762,765,767,770,772,774,776,779,781,783,785,787,789,791,793],{"class":64,"line":159},[62,763,764],{"class":323},"  summarize",[62,766,345],{"class":80},[62,768,769],{"class":87},"text",[62,771,91],{"class":80},[62,773,94],{"class":76},[62,775,356],{"class":80},[62,777,778],{"class":87}," instructions",[62,780,142],{"class":72},[62,782,91],{"class":80},[62,784,94],{"class":76},[62,786,367],{"class":80},[62,788,370],{"class":76},[62,790,373],{"class":80},[62,792,94],{"class":76},[62,794,387],{"class":80},[62,796,797],{"class":64,"line":172},[62,798,460],{"class":97},[62,800,801],{"class":64,"line":185},[62,802,233],{"class":80},[29,804,805,806,809],{},"The interface describes what the ",[238,807,808],{},"app"," needs (a score, a reason, a brief), not what any particular model's API looks like. There are two implementations. The real one goes through OpenRouter, a service that puts most major models behind one API, so switching models is an environment variable rather than a code change. The other is a mock that returns deterministic fake analyses, so the whole pipeline can run in development and in tests without an API key or a bill.",[29,811,812],{},"The interesting part is how the mock is kept out of production. Once it's in the database, a fake sentiment score looks exactly like a real one. If a deploy ever started without its API key and quietly fell back to the mock, customers would be reading invented analysis with nothing to tell them so. So the code that picks the provider refuses to start the app at all in that situation:",[52,814,817],{"className":54,"code":815,"filename":816,"language":57,"meta":58,"style":58},"if (config.aiProvider === 'openrouter') {\n  if (config.openRouterApiKey) return new OpenRouterProvider(config)\n  if (config.isProduction) {\n    throw new Error('AI_PROVIDER=openrouter but OPENROUTER_API_KEY is empty. Refusing to start in production.')\n  }\n}\nelse if (config.isProduction) {\n  throw new Error('AI_PROVIDER=mock is not permitted in production.')\n}\nreturn new MockProvider()   \u002F\u002F development and tests only\n","apps\u002Fapi\u002Fsrc\u002Fmodules\u002Fai\u002Fai.module.ts",[47,818,819,854,886,903,924,929,933,953,973,977],{"__ignoreMap":58},[62,820,821,824,827,829,832,835,838,842,846,849,852],{"class":64,"line":65},[62,822,823],{"class":68},"if",[62,825,826],{"class":80}," (",[62,828,348],{"class":87},[62,830,831],{"class":80},".",[62,833,834],{"class":87},"aiProvider",[62,836,837],{"class":72}," ===",[62,839,841],{"class":840},"scnC2"," '",[62,843,845],{"class":844},"spP0B","openrouter",[62,847,848],{"class":840},"'",[62,850,851],{"class":80},")",[62,853,81],{"class":80},[62,855,856,859,861,863,865,868,870,873,876,879,881,883],{"class":64,"line":84},[62,857,858],{"class":68},"  if",[62,860,826],{"class":80},[62,862,348],{"class":87},[62,864,831],{"class":80},[62,866,867],{"class":87},"openRouterApiKey",[62,869,851],{"class":80},[62,871,872],{"class":68}," return",[62,874,875],{"class":72}," new",[62,877,878],{"class":323}," OpenRouterProvider",[62,880,345],{"class":80},[62,882,348],{"class":87},[62,884,885],{"class":80},")\n",[62,887,888,890,892,894,896,899,901],{"class":64,"line":101},[62,889,858],{"class":68},[62,891,826],{"class":80},[62,893,348],{"class":87},[62,895,831],{"class":80},[62,897,898],{"class":87},"isProduction",[62,900,851],{"class":80},[62,902,81],{"class":80},[62,904,905,908,910,913,915,917,920,922],{"class":64,"line":112},[62,906,907],{"class":68},"    throw",[62,909,875],{"class":72},[62,911,912],{"class":323}," Error",[62,914,345],{"class":80},[62,916,848],{"class":840},[62,918,919],{"class":844},"AI_PROVIDER=openrouter but OPENROUTER_API_KEY is empty. Refusing to start in production.",[62,921,848],{"class":840},[62,923,885],{"class":80},[62,925,926],{"class":64,"line":125},[62,927,928],{"class":80},"  }\n",[62,930,931],{"class":64,"line":136},[62,932,233],{"class":80},[62,934,935,938,941,943,945,947,949,951],{"class":64,"line":149},[62,936,937],{"class":68},"else",[62,939,940],{"class":68}," if",[62,942,826],{"class":80},[62,944,348],{"class":87},[62,946,831],{"class":80},[62,948,898],{"class":87},[62,950,851],{"class":80},[62,952,81],{"class":80},[62,954,955,958,960,962,964,966,969,971],{"class":64,"line":159},[62,956,957],{"class":68},"  throw",[62,959,875],{"class":72},[62,961,912],{"class":323},[62,963,345],{"class":80},[62,965,848],{"class":840},[62,967,968],{"class":844},"AI_PROVIDER=mock is not permitted in production.",[62,970,848],{"class":840},[62,972,885],{"class":80},[62,974,975],{"class":64,"line":172},[62,976,233],{"class":80},[62,978,979,982,984,987,990],{"class":64,"line":185},[62,980,981],{"class":68},"return",[62,983,875],{"class":72},[62,985,986],{"class":323}," MockProvider",[62,988,989],{"class":80},"()",[62,991,992],{"class":97},"   \u002F\u002F development and tests only\n",[29,994,995],{},"A crash at boot is loud and immediate, and it happens before any user sees anything. A silent fallback is none of those things. That trade is worth making for any \"safe default\" that stops being safe in production.",[997,998,1001],"callout",{"title":999,"type":1000},"The takeaway","note",[29,1002,1003],{},"None of these four ideas is complicated on its own: one shape, adapters at the edges, capabilities instead of type checks, queues between stages. Together they mean a new platform is one new class, and nothing downstream of it has to change.",[1005,1006,1007],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sTPum, html code.shiki .sTPum{--shiki-default:#1E754F;--shiki-dark:#4D9375}html pre.shiki code .s5TCs, html code.shiki .s5TCs{--shiki-default:#AB5959;--shiki-dark:#CB7676}html pre.shiki code .s_NWU, html code.shiki .s_NWU{--shiki-default:#2E8F82;--shiki-dark:#5DA994}html pre.shiki code .si6no, html code.shiki .si6no{--shiki-default:#999999;--shiki-dark:#666666}html pre.shiki code .s9nN2, html code.shiki .s9nN2{--shiki-default:#B07D48;--shiki-dark:#BD976A}html pre.shiki code .snYqZ, html code.shiki .snYqZ{--shiki-default:#A0ADA0;--shiki-dark:#758575DD}html pre.shiki code .s_xSY, html code.shiki .s_xSY{--shiki-default:#59873A;--shiki-dark:#80A665}html pre.shiki code .scnC2, html code.shiki .scnC2{--shiki-default:#B5695977;--shiki-dark:#C98A7D77}html pre.shiki code .spP0B, html code.shiki .spP0B{--shiki-default:#B56959;--shiki-dark:#C98A7D}",{"title":58,"searchDepth":84,"depth":101,"links":1009},[1010,1011,1012,1013,1014],{"id":41,"depth":84,"text":42},{"id":255,"depth":84,"text":256},{"id":408,"depth":84,"text":409},{"id":511,"depth":84,"text":512},{"id":618,"depth":84,"text":619},"How Clairwire stops a dozen different platforms from becoming a dozen special cases, with one normalized shape, adapters, capability flags, job queues and an AI layer behind an interface.","md",false,"personal",{},{},{"title":1022},"Architecture",999,"\u002Fprojects\u002Fclairwire\u002Farchitecture",null,{"title":24,"description":1015},[],"projects\u002Fclairwire\u002F1.architecture","37HsNMNteWCYNemmQroLGTqB0B6s3vDzIow5lFsczhU",[1031,1040,1042],{"path":1032,"title":1033,"stem":1034,"navigation":673,"description":1035,"preview":1036},"\u002Fprojects\u002Fclairwire","Clairwire","projects\u002Fclairwire\u002Findex","Media monitoring for PR teams. Everything said about your clients in one place, and an alert when it matters.",{"src":1037,"alt":1038,"og":1039},"\u002Fprojects\u002Fclairwire\u002Fpreview.webp","Clairwire: 'Every mention. Understood.' beside an analysis card showing sentiment, credibility and virality scores with a plain-language reason","\u002Fprojects\u002Fclairwire\u002Fhero.png",{"path":1024,"title":24,"stem":1028,"navigation":1041,"description":1015,"preview":1025},{"title":1022},{"path":1043,"title":1044,"stem":1045,"navigation":1046,"description":1048,"preview":1025},"\u002Fprojects\u002Fclairwire\u002Frelevance","Relevance, not keyword hits","projects\u002Fclairwire\u002F2.relevance",{"title":1047},"Relevance","Why a mention containing a client's name isn't the same as a mention about them, how Clairwire records the difference, and how the change shipped without emptying anyone's dashboard.",[1050,1051,1052,1053,1054,1055,1056,1057,1058,1059,1060,1061,1062],"TypeScript","Vue 3","Nuxt","Pinia","Tailwind CSS","Python","FastAPI","SQLAlchemy","Pydantic","PostgreSQL","Supabase","GCP","Firebase",1790471331918]