<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alexander Lukashov</title>
    <description>The latest articles on DEV Community by Alexander Lukashov (@alexander_lukashov).</description>
    <link>https://dev.to/alexander_lukashov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3831763%2F6d3c59cf-277d-4a77-ae95-a56a85f0f50b.png</url>
      <title>DEV Community: Alexander Lukashov</title>
      <link>https://dev.to/alexander_lukashov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alexander_lukashov"/>
    <language>en</language>
    <item>
      <title>Our MCP server was fine. Cloudflare was returning HTML.</title>
      <dc:creator>Alexander Lukashov</dc:creator>
      <pubDate>Thu, 10 Sep 2026 09:50:32 +0000</pubDate>
      <link>https://dev.to/alexander_lukashov/our-mcp-server-was-fine-cloudflare-was-returning-html-34c6</link>
      <guid>https://dev.to/alexander_lukashov/our-mcp-server-was-fine-cloudflare-was-returning-html-34c6</guid>
      <description>&lt;p&gt;This is what our MCP endpoint returned to a client speaking JSON-RPC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="m"&gt;403&lt;/span&gt;
&lt;span class="na"&gt;content-type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;text/html; charset=UTF-8&lt;/span&gt;
&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloudflare&lt;/span&gt;

Attention Required! | Cloudflare
Please enable cookies.
Sorry, you have been blocked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A program was being asked to enable cookies.&lt;/p&gt;

&lt;p&gt;We did not see that for three days, because that is not what the client reported. The client reported a parse failure, and a parse failure points at your own serialization, not at a machine four thousand kilometres away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looked like from inside
&lt;/h2&gt;

&lt;p&gt;The connector could not talk to our server. The tools never listed. Everything we could check on our side looked correct: valid JSON-RPC, right content type, right status codes, protocol revision we support.&lt;/p&gt;

&lt;p&gt;So we did the reasonable thing and started fixing the things that were wrong but adjacent.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /{prefix}/_mcp&lt;/code&gt; was returning JSON. Per the Streamable HTTP spec it should return &lt;code&gt;405&lt;/code&gt; if the server does not offer an SSE stream on GET. We changed it. It is a real fix and we kept it.&lt;/p&gt;

&lt;p&gt;It changed nothing.&lt;/p&gt;

&lt;p&gt;That is the value of shipping one variable at a time. If we had bundled that change with anything else, the next result would have been unreadable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that actually settled it
&lt;/h2&gt;

&lt;p&gt;We wrote a minimal MCP server that imitated our own response shape exactly: &lt;code&gt;application/json&lt;/code&gt; on POST, &lt;code&gt;405&lt;/code&gt; on GET. A hundred lines, no auth, no database, nothing of ours in it. Then we exposed it through an ngrok tunnel and pointed the same connector at it.&lt;/p&gt;

&lt;p&gt;It worked immediately. Tools listed, first try.&lt;/p&gt;

&lt;p&gt;That one result eliminated most of the search space. Our response shape was fine, because a server with the same shape worked. The protocol was fine. The connector was fine. What was left was everything between the connector and our application, which is exactly the part we had not been looking at, because it is not in the repository.&lt;/p&gt;

&lt;p&gt;If you take one thing from this: &lt;strong&gt;when you cannot find the bug in your code, build something that cannot possibly contain it and see whether the problem follows.&lt;/strong&gt; The mimic server took an hour. It saved the rest of the week.&lt;/p&gt;

&lt;h2&gt;
  
  
  It was the user agent
&lt;/h2&gt;

&lt;p&gt;Here is the same request, same body, same endpoint, varying only &lt;code&gt;User-Agent&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user-agent                          code  content-type              bytes
Claude-User/1.0; +claude.ai          200  application/json            156
Claude-SearchBot/1.0; +claude.ai     200  application/json            156
anthropic-ai                         200  application/json            156
curl/8.7.1                           200  application/json            156
ClaudeBot/1.0; +claude.ai/bot        403  text/html; charset=UTF-8   4543
GPTBot/1.2; +openai.com/gptbot       403  text/html; charset=UTF-8   4543
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Cloudflare managed rule, on by default, blocking AI crawlers. Nobody on our side had turned it on. Nobody had turned it off either, which is the point.&lt;/p&gt;

&lt;p&gt;Look at the fourth row. &lt;strong&gt;&lt;code&gt;curl&lt;/code&gt; passes.&lt;/strong&gt; Every tool you reach for when something is broken is on the allowlist, which is why this survives so long. You test the endpoint, it answers, you conclude the endpoint is fine, and you go back to reading your own code.&lt;/p&gt;

&lt;p&gt;One limitation worth stating: every row above was sent by curl, so the TLS fingerprint was held constant while only the header changed. That proves the user agent alone is enough to trigger the block. It does not prove the user agent is the only signal, and bot detection also reads the ClientHello. If you allow an agent by name and it still gets refused, that is the next variable to vary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The taxonomy is where it goes wrong
&lt;/h2&gt;

&lt;p&gt;Anthropic runs three crawlers on purpose, so that a site owner can make three separate decisions.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ClaudeBot&lt;/code&gt; collects content that may contribute to training. &lt;code&gt;Claude-User&lt;/code&gt; fetches a page because a person just asked Claude a question. &lt;code&gt;Claude-SearchBot&lt;/code&gt; indexes for search results. Three names, three robots.txt entries, three different trade-offs. The split exists precisely so you can refuse training and stay reachable.&lt;/p&gt;

&lt;p&gt;Cloudflare has a category for exactly that middle case. It is called AI Assistant, and here is who is in it, as the crawler list shows it today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ChatGPT-User            OpenAI        AI Assistant
MistralAI-User          Mistral       AI Assistant
Perplexity-User         Perplexity    AI Assistant
DuckAssistBot           DuckDuckGo    AI Assistant
Meta-ExternalFetcher    Meta          AI Assistant
Manus Bot               Manus         AI Assistant

Claude-User             Anthropic     AI Crawler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the naming. &lt;code&gt;ChatGPT-User&lt;/code&gt;, &lt;code&gt;MistralAI-User&lt;/code&gt;, &lt;code&gt;Perplexity-User&lt;/code&gt;, &lt;code&gt;Claude-User&lt;/code&gt;. One convention, one job, four vendors. Three of them are filed as assistants and one is filed as a crawler.&lt;/p&gt;

&lt;p&gt;Meta gets it right twice over: &lt;code&gt;Meta-ExternalAgent&lt;/code&gt; is an AI Crawler and &lt;code&gt;Meta-ExternalFetcher&lt;/code&gt; is an AI Assistant, which is the same split Anthropic makes and the same split OpenAI makes.&lt;/p&gt;

&lt;p&gt;So this is not a taxonomy that has failed to catch up with agents. The category exists, it is populated, and six vendors are in it correctly. One row is filed wrong.&lt;/p&gt;

&lt;p&gt;The consequence lands on one vendor's users. Block the AI Crawler category, which is what the default does, and ChatGPT-User, Perplexity-User and the rest keep working. Claude-User does not. You made one policy decision and got a different outcome depending on which assistant your customer happens to use, without being told that is what you were choosing.&lt;/p&gt;

&lt;p&gt;Fixing it on your side means allowing that agent by name, and first you have to work out that you need to. Ours now refuses the training crawlers and passes the user-initiated traffic, which took a deliberate change rather than anything the default did for us.&lt;/p&gt;

&lt;p&gt;Fixing it properly is one row in Cloudflare's own table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing in the response says you were blocked
&lt;/h2&gt;

&lt;p&gt;This is what makes it expensive rather than annoying.&lt;/p&gt;

&lt;p&gt;There is no JSON error. No error code. No header explaining the refusal. &lt;code&gt;cf-mitigated&lt;/code&gt; is absent. All you get that a machine can read is &lt;code&gt;server: cloudflare&lt;/code&gt; and a &lt;code&gt;cf-ray&lt;/code&gt; id, and neither of those means anything to a JSON-RPC client that expected an object and got a document.&lt;/p&gt;

&lt;p&gt;So the client raises a parse error, and a parse error is a lie about where the problem is. It points inward, at your serializer, your framework, your content type. Every hypothesis it suggests is about code you own.&lt;/p&gt;

&lt;p&gt;The part almost nobody uses: this is configurable. The same screen has a Configure Response control that sets the status code and message returned to blocked crawlers. If the thing behind your CDN is an API, a JSON body with an explicit reason costs nothing and turns three days of debugging into one line in a log. Blocking somebody is fine. Blocking them in a format they cannot parse is a choice you probably did not mean to make.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw evidence
&lt;/h2&gt;

&lt;p&gt;Both sides of the same request, so you can see what a client has to work with.&lt;/p&gt;

&lt;p&gt;Allowed, &lt;code&gt;Claude-User&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;content-type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;application/json&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;-32002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Server not initialized"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"hint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Call initialize first and reuse Mcp-Session-Id header."&lt;/span&gt;&lt;span class="p"&gt;}}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is our server refusing the call, correctly, in the protocol, with a hint saying what to do next. 156 bytes.&lt;/p&gt;

&lt;p&gt;Blocked, &lt;code&gt;GPTBot&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="m"&gt;403&lt;/span&gt;
&lt;span class="na"&gt;content-type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;text/html; charset=UTF-8&lt;/span&gt;
&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloudflare&lt;/span&gt;
&lt;span class="na"&gt;cf-ray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;a38529b8ec8bc239-BEG&lt;/span&gt;

Attention Required! | Cloudflare
Please enable cookies.
Sorry, you have been blocked
You are unable to access fxns.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;4543 bytes of HTML. No &lt;code&gt;cf-mitigated&lt;/code&gt; header, no JSON, nothing naming a rule. A client written against the MCP spec has no branch for this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same thing decides whether agents can discover you
&lt;/h2&gt;

&lt;p&gt;Agent cards, OAuth protected-resource metadata, MCP discovery documents: all of it is converging on &lt;code&gt;/.well-known/&lt;/code&gt;. That only works if the thing fetching it is allowed to fetch.&lt;/p&gt;

&lt;p&gt;Ours is reachable, and here is how you tell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/.well-known/oauth-protected-resource     404  application/json
/definitely-not-a-route                   404  application/json

body: {"message":"Route not found","error_code":"route_not_found",...}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 404 is ours. Same JSON envelope as any unknown route, which means the request reached the application. A block page instead of your own error format means it did not. &lt;strong&gt;Check whose 404 it is, not whether you got one.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The five minute version
&lt;/h2&gt;

&lt;p&gt;Take your own endpoint and run the request you care about six times, changing only the user agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;ua &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"curl/8.7.1"&lt;/span&gt; &lt;span class="s2"&gt;"Claude-User/1.0"&lt;/span&gt; &lt;span class="s2"&gt;"ClaudeBot/1.0"&lt;/span&gt; &lt;span class="s2"&gt;"GPTBot/1.2"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="s2"&gt; %{http_code} %{content_type}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://your.endpoint/_mcp &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it again in a month. The category a crawler sits in is Cloudflare's data, not yours, and it can be refiled without anything changing on your side, including the test you ran to prove your rule was safe. Check per user agent, not per category, and check more than once.&lt;/p&gt;

&lt;p&gt;If the codes differ, or the content types do, the layer in front of you is making decisions you did not make. Watch the content type especially: a proxy can answer 200 with an HTML body, and no status check will ever catch that one. In Cloudflare they live under AI Crawl Control, in the Security section, one row per crawler. Then decide which of those decisions you actually want, because refusing training crawlers and refusing your customers' agents are not the same choice, and by default they are the same switch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Updated after publishing: added the AI Assistant category table after a reader asked how the vendors line up, noted that the user agent matrix held the TLS fingerprint constant, and extended the check to content type after a reader pointed out that a proxy can answer 200 with an HTML body.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>mcp</category>
      <category>debugging</category>
      <category>ai</category>
    </item>
    <item>
      <title>One question, 437,000 tokens: what real agents found in our MCP server</title>
      <dc:creator>Alexander Lukashov</dc:creator>
      <pubDate>Tue, 08 Sep 2026 12:48:00 +0000</pubDate>
      <link>https://dev.to/alexander_lukashov/one-question-437000-tokens-what-real-agents-found-in-our-mcp-server-1flc</link>
      <guid>https://dev.to/alexander_lukashov/one-question-437000-tokens-what-real-agents-found-in-our-mcp-server-1flc</guid>
      <description>&lt;p&gt;One question. 437,000 input tokens.&lt;/p&gt;

&lt;p&gt;Not a hard question either. An agent connected to our MCP server, asked something a support engineer answers in a sentence, and worked its way there through twenty tool calls, each one dragging every earlier answer along behind it.&lt;/p&gt;

&lt;p&gt;Nothing was broken while that happened. The server answered &lt;code&gt;initialize&lt;/code&gt; correctly, spoke the 2025-03-26 revision, returned valid JSON-RPC to everything we threw at it. All of which turned out to be beside the point.&lt;/p&gt;

&lt;p&gt;So we pointed real agents at production and watched. 18 scenarios, two vendors, a 5 dollar budget that we topped up once. This is the long version with the traces in it. There is a &lt;a href="https://foxnose.net/blog/what-real-agents-found-in-our-mcp-server" rel="noopener noreferrer"&gt;shorter one on our blog&lt;/a&gt; if you only want the conclusions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server is
&lt;/h2&gt;

&lt;p&gt;Briefly, because it shapes everything below.&lt;/p&gt;

&lt;p&gt;FoxNose stores content as collections: schema-defined records with typed fields, some of them vector indexed. The MCP server is &lt;a href="https://foxnose.net/product/knowledge-mcp" rel="noopener noreferrer"&gt;generated from that schema&lt;/a&gt; and &lt;a href="https://foxnose.net/docs/agent-native" rel="noopener noreferrer"&gt;served from the same URL prefix as the REST API&lt;/a&gt;. Fixed catalog of seven tools regardless of how many collections exist, five read and two optional write.&lt;/p&gt;

&lt;p&gt;Collections are what an agent chooses between, so a badly described collection is effectively invisible. And the agent inherits exactly the rights of the API key it connects with, so there is no second allowed-tools list drifting out of sync with the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the harness actually is
&lt;/h2&gt;

&lt;p&gt;A scenario is a question in plain English, a set of tools, and a check.&lt;/p&gt;

&lt;p&gt;The checks are where we made the most mistakes, so start there. They do not look at the answer text. Model output moves between runs, and a suite that asserts on wording is a suite you quietly stop trusting. They look at the trace: which tools ran, in what order, with what arguments, which errors came back, how many tokens the whole thing burned.&lt;/p&gt;

&lt;p&gt;A check is a small predicate over the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;any_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;no_tool_errors&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nf"&gt;recovered_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown_resource&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;then&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second branch exists because of a mistake I will get to at the end.&lt;/p&gt;

&lt;p&gt;We ran everything twice, on Anthropic's hosted MCP connector and on OpenAI's hosted MCP in the Responses API. Same server, same questions, two clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the bill is round trips, not bytes
&lt;/h2&gt;

&lt;p&gt;Back to those 437,000 tokens.&lt;/p&gt;

&lt;p&gt;A hosted connector runs the tool loop on the vendor's side. Every iteration re-sends the whole conversation to the model. So a tool call does not cost what it returned. It costs what it returned, times the number of turns after it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdihz86hgxquzafwkyjkt.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdihz86hgxquzafwkyjkt.webp" alt="Each connector iteration re-sends the whole conversation, so every earlier result is paid for again" width="709" height="999"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Input tokens per scenario, before and after we shipped truncated search results and changed the page size:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;scenario&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;R4&lt;/td&gt;
&lt;td&gt;437,751&lt;/td&gt;
&lt;td&gt;78,072&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N5&lt;/td&gt;
&lt;td&gt;175,421&lt;/td&gt;
&lt;td&gt;26,078&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R5&lt;/td&gt;
&lt;td&gt;128,800&lt;/td&gt;
&lt;td&gt;17,054&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R8&lt;/td&gt;
&lt;td&gt;115,027&lt;/td&gt;
&lt;td&gt;12,607&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R7&lt;/td&gt;
&lt;td&gt;93,770&lt;/td&gt;
&lt;td&gt;123,831&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same questions, same collections, same models. The only thing that moved is how much each call handed back and how many calls it took to get there.&lt;/p&gt;

&lt;p&gt;R7 went the wrong way, and it is the clearest evidence for the point. It compares two collections, five hits each was not enough, so the agent searched again and again and finished at fourteen tool calls. A smaller page moves cost out of the payload and into the turns, and the turns are the expensive part. One scenario out of nine moving backwards was a trade we took, but it is the shape to watch.&lt;/p&gt;

&lt;p&gt;These numbers are from the Anthropic connector. Every scenario ran on both, but the token comparison above is single vendor.&lt;/p&gt;

&lt;p&gt;Twenty calls against fourteen is not twenty over fourteen. Everyone tunes model choice. Almost nobody counts round trips.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we shipped.&lt;/strong&gt; Text fields in search results are capped at 1000 characters by default, and every field that got cut carries a marker with its path, its locale, and the original length. So the agent knows per field whether it is holding a fragment. An 11,917 character body arrives as 1000 and says so. &lt;code&gt;get_record&lt;/code&gt; hands over all 11,917.&lt;/p&gt;

&lt;p&gt;Page size went to a default of 5 with a maximum of 100, and both numbers live in the tool's JSON schema, so the model reads the range in the catalog before its first call.&lt;/p&gt;

&lt;p&gt;We could not choose a good page size for everybody, so we stopped trying and told the caller the range instead. Publishing the bounds mattered more than which number we picked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: your error strings are control flow
&lt;/h2&gt;

&lt;p&gt;An agent hit an error, read the hint attached to it, did exactly what the hint said, failed the same way, read the same hint again. No crash. Nothing logged as wrong. It spent its entire iteration budget being obedient to a wrong instruction.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcp2lv07r48maycnjb07o.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcp2lv07r48maycnjb07o.webp" alt="The agent follows a hint that points at the wrong failure and loops, while the real cause is never stated" width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One line out of place caused it. We return structured errors, an error code plus a message plus a hint saying what to do next, and one code had been handed a different error's hint. Code right, status right, wrong sentence.&lt;/p&gt;

&lt;p&gt;A person reads a wrong hint and shrugs. A model does not shrug. The hint is the next instruction.&lt;/p&gt;

&lt;p&gt;Here is the same mechanism working, which is the part I actually want to show you. Real trace, from a scenario where the agent guessed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;discover_resources                                  ok
search_records  resource_id="documentation"         unknown_resource
                hint: call discover_resources and reuse exact resource_id
search_records  resource_id="kb_foxnose"            ok
get_record      MbJbdnzj9fJw                        ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four calls. It invented an identifier, got told exactly how to recover, recovered, answered. The whole exchange costs less than one wide search. Cheap recovery is something you build on purpose, and it only works when every hint points at its own failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: a JSON-RPC notification is defined by the absent id
&lt;/h2&gt;

&lt;p&gt;Small, pure protocol, which is why it lives here and not in the short version.&lt;/p&gt;

&lt;p&gt;In JSON-RPC 2.0 a notification is a request with no &lt;code&gt;id&lt;/code&gt; member. That is the entire definition. You must not reply to one, and you must reply to everything else.&lt;/p&gt;

&lt;p&gt;Our server was deciding by method name. Anything starting with &lt;code&gt;notifications/&lt;/code&gt; got treated as a notification.&lt;/p&gt;

&lt;p&gt;Those two rules agree nearly always, which is how it survived as long as it did. They part company when a client sends &lt;code&gt;notifications/initialized&lt;/code&gt; with an &lt;code&gt;id&lt;/code&gt;. That is a request. The spec says answer it. We sent silence.&lt;/p&gt;

&lt;p&gt;No client we tested does this. It is the kind of thing that surfaces a year from now, in an integration you did not write, with a symptom that looks nothing like the cause. We key off the presence of &lt;code&gt;id&lt;/code&gt; now, like the spec says.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: the client decides your authentication
&lt;/h2&gt;

&lt;p&gt;We had two auth schemes, both requiring you to write your own &lt;code&gt;Authorization&lt;/code&gt; header. Never a problem, because every client we developed against was one where we wrote the header ourselves.&lt;/p&gt;

&lt;p&gt;Hosted connectors do not work that way. One token field, always sent as &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;. That is the whole interface. No configuration, no workaround.&lt;/p&gt;

&lt;p&gt;So our server could not be used from the two clients most people reach for first. Not broken. Unusable. And invisible from the inside, which is the part that stuck with me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we shipped.&lt;/strong&gt; Bearer tokens: opaque, 47 characters, no colon so they cannot be confused with a &lt;code&gt;public:private&lt;/code&gt; pair, shown once at issue, bound to an existing API key and exactly as powerful as it.&lt;/p&gt;

&lt;p&gt;Then we measured how long it actually takes to cut someone off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deleting the key            took effect after 305 s
emptying the key's role     took effect after 284 s
revoking a bearer token     took effect on the next request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first two are a permission snapshot cache expiring. Revoking the token skips it, because the token itself stops resolving. Worth knowing before the day you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 5: two vendors, two shapes
&lt;/h2&gt;

&lt;p&gt;We wrote the OpenAI transport after the Anthropic one and assumed it was the same code with the nouns swapped. It would have been wrong in three places.&lt;/p&gt;

&lt;p&gt;Tool call arguments arrive parsed in one and as a JSON string in the other, so you parse in one transport and must not in the other.&lt;/p&gt;

&lt;p&gt;The OpenAI SDK types the error field on an &lt;code&gt;mcp_call&lt;/code&gt; as an optional string. The wire sends a dict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp_tool_execution_error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;json&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trust the annotation and you will eventually call a string method on a dict, in a branch that only runs when something has already gone wrong. Great place for a second bug.&lt;/p&gt;

&lt;p&gt;The third one will bite anyone building on the Anthropic connector. It runs at most 10 tool iterations per request. If the agent has not finished, you get &lt;code&gt;stop_reason: "pause_turn"&lt;/code&gt; with the partial work. HTTP 200. No error field anywhere.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8w1y4bak4c056tx5ke45.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8w1y4bak4c056tx5ke45.webp" alt="A client that only checks for end_turn silently accepts a truncated answer; handling pause_turn continues the loop" width="799" height="206"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A client that checks only for &lt;code&gt;end_turn&lt;/code&gt; records a success and shows a truncated answer. We caught it because a scenario answered half a question and the harness passed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where our own tests were wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A check that forbids all errors forbids recovery.&lt;/strong&gt; Our &lt;code&gt;no_tool_errors&lt;/code&gt; check failed three scenarios that were completely fine. In each one the agent guessed, got a hint, corrected itself, answered. The trace higher up in this post is one of them.&lt;/p&gt;

&lt;p&gt;We had spent real effort making guessing cheap, then written a check that punished guessing. That is where the &lt;code&gt;any_of&lt;/code&gt; at the top comes from. If your surface is meant to be explored, your tests have to let it be explored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A negative result is not a result.&lt;/strong&gt; We checked that a key from one environment cannot read another. It could not, and I nearly wrote that down as proof of isolation.&lt;/p&gt;

&lt;p&gt;Then we checked whether the key could read anything at all. "Access denied" looks identical whether the isolation held or the credential was already dead. This caught us three separate times: isolation, bearer auth, revocation. Each of those needed a positive control sitting beside it, a case you know should succeed. Without one, all you have proven is that broken things stay broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;Under ten dollars of API spend across about ten days. The harness is a few hundred lines. Reading traces took far longer than running them.&lt;/p&gt;

&lt;p&gt;Final state is 18 out of 18 on both vendors, against production.&lt;/p&gt;

&lt;p&gt;If you are shipping an MCP server: take five real questions, run them through a hosted connector, and read the traces rather than the answers. Count the calls. Look at what each one handed back. Then ask whether a model that cannot see your dashboard could have done better than that.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Updated after publishing: added the one scenario that regressed, and noted that the token numbers come from the Anthropic connector.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>testing</category>
      <category>api</category>
    </item>
    <item>
      <title>Hybrid search for your app in an afternoon, for $0</title>
      <dc:creator>Alexander Lukashov</dc:creator>
      <pubDate>Tue, 28 Jul 2026 18:00:44 +0000</pubDate>
      <link>https://dev.to/alexander_lukashov/hybrid-search-for-your-app-in-an-afternoon-for-0-5cjg</link>
      <guid>https://dev.to/alexander_lukashov/hybrid-search-for-your-app-in-an-afternoon-for-0-5cjg</guid>
      <description>&lt;p&gt;Somebody's card gets declined. They open your help center and type "my money disappeared". Your article is called "Troubleshooting failed payments". Not one word in common, so search returns nothing and they write to support instead.&lt;/p&gt;

&lt;p&gt;Keyword search only finds what you already named correctly. Semantic search fixes that, and then misses the other way: ask it for the error code &lt;code&gt;NB-2001&lt;/code&gt; and it hands you something thematically close instead of the page with that string in it. Hybrid runs both and merges them, so exact matches take the top and meaning fills in below.&lt;/p&gt;

&lt;p&gt;The usual price for that is a vector database, an embedding pipeline and a sync job between your content and your index. Here is the version with none of those. It took an afternoon and cost nothing.&lt;/p&gt;

&lt;p&gt;Quick disclosure: I built FoxNose, and Loquix is mine too. Everything below runs on a free account, and there is a short list at the end of cases where you should use something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two pieces
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://foxnose.net" rel="noopener noreferrer"&gt;FoxNose&lt;/a&gt;&lt;/strong&gt; stores the content and does the search. Mark a field &lt;code&gt;searchable&lt;/code&gt; and &lt;code&gt;vectorizable&lt;/code&gt; and every record you publish is indexed for keywords and vectors. Hybrid search is on the free tier, and for a small product the free tier is enough to just run this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://loquix.dev" rel="noopener noreferrer"&gt;Loquix&lt;/a&gt;&lt;/strong&gt; is MIT web components, including a &lt;code&gt;&amp;lt;loquix-search-dialog&amp;gt;&lt;/code&gt; you can drop in. Plain custom elements, so React, Vue or nothing at all.&lt;/p&gt;

&lt;p&gt;The browser calls the search API directly. No backend of yours in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Describe the content
&lt;/h2&gt;

&lt;p&gt;Sign up and create a project. FoxNose provisions one environment for it, &lt;code&gt;Production&lt;/code&gt;, and you can add more later for staging or previews. An environment is a separate database with its own API host at &lt;code&gt;https://{environment_key}.fxns.io&lt;/code&gt;. Note that key down, you need it in a minute.&lt;/p&gt;

&lt;p&gt;Inside the environment, create a collection. Mine is called &lt;code&gt;articles&lt;/code&gt;. Then give it a schema in the visual editor: fields, types, flags, publish. Schema versions are drafts until you publish them, so there is no way to half-apply a change.&lt;/p&gt;

&lt;p&gt;I used a help center for a fictional product, 24 articles:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Flags&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;searchable, vectorizable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;slug&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;summary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;searchable, vectorizable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;body&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;searchable, vectorizable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;category&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;searchable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is the whole search setup. &lt;code&gt;searchable&lt;/code&gt; indexes the field for querying, &lt;code&gt;vectorizable&lt;/code&gt; means embeddings get generated on every publish. No embedding code, nothing to keep in sync, no index to create.&lt;/p&gt;

&lt;p&gt;Two things worth knowing before you load anything. Full-text search lives on &lt;code&gt;text&lt;/code&gt; fields, not &lt;code&gt;string&lt;/code&gt;, so anything people will actually search inside wants to be &lt;code&gt;text&lt;/code&gt;. And a field's type cannot be edited later, only recreated: you remove the field and add a new one in its place, then load the content again. Cheap now, annoying after you have data.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Load the content
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @foxnose/sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three values go into your &lt;code&gt;.env&lt;/code&gt; here: the environment key from a minute ago, the collection key, and a management API key you create in the environment settings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// seed.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ManagementClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SimpleKeyAuth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@foxnose/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;articles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./articles.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ManagementClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;environmentKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FOXNOSE_ENV_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SimpleKeyAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FOXNOSE_MGMT_PUBLIC_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FOXNOSE_MGMT_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;batchUpsertResources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FOXNOSE_COLLECTION_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;external_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;node --env-file=.env seed.js&lt;/code&gt; on Node 20.6 or newer, so you do not need dotenv for this.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;external_id&lt;/code&gt; makes it an upsert, so you can re-run this as often as you like. Each write validates the record, generates the embeddings, indexes it and publishes it.&lt;/p&gt;

&lt;p&gt;You can also just type content into the visual editor, which is the point for a real product: support people edit help articles without a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Put the search online
&lt;/h2&gt;

&lt;p&gt;Content is served through what FoxNose calls a Flux API. Create one, give it a prefix, connect the collection. Three settings matter for calling it from a browser:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;connect the collection with both &lt;code&gt;get_many&lt;/code&gt; and &lt;code&gt;get_one&lt;/code&gt;, or results come back without content&lt;/li&gt;
&lt;li&gt;make the API public, meaning anonymous reads (writes still need a key)&lt;/li&gt;
&lt;li&gt;turn on &lt;strong&gt;Allow all origins&lt;/strong&gt; so the API accepts calls from a browser at all (fine here, these articles are public anyway)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now you have an endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://{environment_key}.fxns.io/help-center/articles/_search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No key in your frontend, no proxy to hide that key.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Search
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// flux.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://YOUR_ENV_KEY.fxns.io/help-center&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hybridSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/articles/_search`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;search_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hybrid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;find_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;summary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;vector_search&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;similarity_threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;hybrid_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;vector_weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;text_weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;rerank_results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Search failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One request, two ranked lists, one answer. &lt;code&gt;NB-2001&lt;/code&gt; returns the error codes page. "my money disappeared" returns the payments article. &lt;code&gt;search_mode&lt;/code&gt; also takes &lt;code&gt;text&lt;/code&gt; or &lt;code&gt;vector&lt;/code&gt; if you ever want one side on its own.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;threshold: 0.85&lt;/code&gt; is your typo tolerance: at &lt;code&gt;1.0&lt;/code&gt; matching is exact, lower is fuzzier, so "pyament declined" still lands.&lt;/p&gt;

&lt;p&gt;Then there are two numbers that decide how good this feels, and neither has a universally right value:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;similarity_threshold&lt;/code&gt; is the floor for "close enough" on the semantic side. Every document is a little bit similar to every query, so this is where you cut. Too low and a question about baking bread returns your billing articles. Too high and paraphrases stop working. 0.6 fits these 24 articles.&lt;/p&gt;

&lt;p&gt;The weights settle words against meaning. Text-heavy works for a help center, where people search for strings that literally exist: error codes, plan names, button labels. For a product catalog, where nobody knows your wording, push the vector side up.&lt;/p&gt;

&lt;p&gt;There is no magic here, which I think is the good news. It is arithmetic over two ranked lists. Load your own content, throw your real queries at it, move the two numbers, look at what comes back. Twenty minutes of that beats any default.&lt;/p&gt;

&lt;p&gt;Filters live next to all this: &lt;code&gt;where&lt;/code&gt; takes typed operators over any searchable field, combined with &lt;code&gt;all_of&lt;/code&gt; and &lt;code&gt;any_of&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The dialog
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @loquix/core lit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;loquix-search-dialog&lt;/span&gt;
  &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt;
  &lt;span class="na"&gt;heading=&lt;/span&gt;&lt;span class="s"&gt;"Search help center"&lt;/span&gt;
  &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Search help articles..."&lt;/span&gt;
  &lt;span class="na"&gt;kbd=&lt;/span&gt;&lt;span class="s"&gt;"⌘K"&lt;/span&gt;
  &lt;span class="na"&gt;mode=&lt;/span&gt;&lt;span class="s"&gt;"plain"&lt;/span&gt;
  &lt;span class="na"&gt;hide-answer&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/loquix-search-dialog&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mode="plain"&lt;/code&gt; keeps it a search box and &lt;code&gt;hide-answer&lt;/code&gt; drops the LLM answer block, which is a different article. You get a real modal &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; with a focus trap, keyboard navigation, loading states and a chip row, none of which you write.&lt;/p&gt;

&lt;p&gt;It knows nothing about any backend. It fires events and takes plain arrays:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@loquix/core/tokens/variables.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@loquix/core/define/define-search-dialog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hybridSearch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./flux.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dialog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CATEGORIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;getting-started&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Getting started&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Billing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Account&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;API&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/help/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CATEGORIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;

  &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CATEGORIES&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;searching&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hybridSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a new query starts unfiltered&lt;/span&gt;
    &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Service is too busy. Try again.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loquix-change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;runSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Enter fires submit, Cmd+Enter fires ask even in plain mode, so wire both&lt;/span&gt;
&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loquix-search-submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;runSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loquix-search-ask&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;runSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loquix-search-source-select&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metaKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctrlKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;k&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three notes on that, because they are easy to miss:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.results&lt;/code&gt; and &lt;code&gt;.sources&lt;/code&gt; are JavaScript properties, not attributes. Setting them in HTML does nothing.&lt;/p&gt;

&lt;p&gt;Debounce is yours to add. Every keystroke would otherwise be its own request, and one request counts the same whether it returns one result or twenty five.&lt;/p&gt;

&lt;p&gt;The chips filter what you already fetched, so their counts and their clicks describe the same set of results. No extra request, instant. The same strip is built for a bigger job: give each kind of content its own collection, fire one query per collection in parallel, and each chip becomes a source with its own count as the answers arrive.&lt;/p&gt;

&lt;p&gt;React users: &lt;code&gt;@loquix/react&lt;/code&gt; wraps every component, events arrive as props like &lt;code&gt;onSearchSubmit&lt;/code&gt;. Both packages ship types.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is, and where it stops
&lt;/h2&gt;

&lt;p&gt;Worth being straight about the shape of it: FoxNose is not a search engine. It is a typed content database built for LLM agents and RAG, with hybrid search included, which is why one API holds your records and retrieves them and there is nothing to sync in between.&lt;/p&gt;

&lt;p&gt;A product that does search and only search goes further in specific places: merchandising rules, search analytics, highlighted snippets, facet counts across a whole corpus, US data residency, edge latency at very large scale. All real features, and if your product lives on one of them, use Algolia. For most teams shipping a help center or in-app search, none of them come up in a normal month.&lt;/p&gt;

&lt;p&gt;If you would rather run infrastructure, Typesense and Meilisearch are excellent and free at any volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Take it
&lt;/h2&gt;

&lt;p&gt;The whole thing is in one repo: &lt;a href="https://github.com/loookashow/hybrid-search-help-center" rel="noopener noreferrer"&gt;https://github.com/loookashow/hybrid-search-help-center&lt;/a&gt;. Copy &lt;code&gt;.env.example&lt;/code&gt;, add your keys, then &lt;code&gt;npm install&lt;/code&gt;, &lt;code&gt;npm run seed&lt;/code&gt;, &lt;code&gt;npm run dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two flags on a schema, one fetch, one component, two numbers to tune. Next time I will put a language model on top of the same API so the box can answer in sentences and cite the articles it used.&lt;/p&gt;

&lt;p&gt;Go type "my money disappeared" into your own product's search. Curious what comes back.&lt;/p&gt;

</description>
      <category>search</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why I made my paid product fully free for personal use</title>
      <dc:creator>Alexander Lukashov</dc:creator>
      <pubDate>Tue, 19 May 2026 16:25:33 +0000</pubDate>
      <link>https://dev.to/alexander_lukashov/why-i-made-my-paid-product-fully-free-for-personal-use-5cie</link>
      <guid>https://dev.to/alexander_lukashov/why-i-made-my-paid-product-fully-free-for-personal-use-5cie</guid>
      <description>&lt;p&gt;I spent two months thinking about pricing before I let myself write the pricing page.&lt;/p&gt;

&lt;p&gt;That is not a humblebrag, it slowed everything down. The product was technically ready in March. By April it could have been on sale. But every time I sat down to define the free tier, something did not sit right, and I would close the tab and go fix a bug instead.&lt;/p&gt;

&lt;p&gt;What unstuck it was not a framework, a benchmark, or a YC essay. It was one question I could not answer to my own satisfaction. I want to write about it, because the question is generalizable and I think a lot of solo makers building tools quietly trip over it.&lt;/p&gt;

&lt;p&gt;A bit of context first. I have been a technical product manager at large tech companies for many years, always on B2B SaaS, always with a team, always for someone else's company. The product I was pricing is Notch, a screen recorder for Mac and Windows. It is the first desktop app I have shipped under my own name. So this was the first time I was personally on the line for a pricing decision, with my own money funding the development and my own name on the receiver.&lt;/p&gt;

&lt;h2&gt;
  
  
  The freemium playbook I almost ran
&lt;/h2&gt;

&lt;p&gt;When I sketched the pricing card the first time, it looked almost exactly like what every other screen recorder does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free tier with a 5-project cap, limited by 1080p output quality, and a watermark on exports&lt;/li&gt;
&lt;li&gt;Paid tier at $49 one-time that removes both&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the freemium playbook by default. The free tier is a trial in disguise. Generous enough to demo the product, restrictive enough that anyone using it seriously has to upgrade. Loom does it. Screen Studio does it. Camtasia does it. Tella does it. So did the spreadsheet I was modelling.&lt;/p&gt;

&lt;p&gt;The numbers added up. The conversion funnel made sense. I built the watermark into the export pipeline. I implemented the project counter and a "you've hit your limit" modal. I tested both. They worked.&lt;/p&gt;

&lt;p&gt;I just did not want to ship them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I could not answer
&lt;/h2&gt;

&lt;p&gt;The discomfort was not moral. I had convinced myself the freemium model is honest enough, since the limits are visible upfront. The discomfort was about a specific user I kept picturing.&lt;/p&gt;

&lt;p&gt;That user is someone like me five or ten years ago: an engineer recording their first screencast tutorial for their blog, a student doing a class project, a hobbyist showing their indie game on Mastodon. Someone who is not going to make a dollar from the thing they are recording. Someone for whom $49, even one-time, is meaningful money.&lt;/p&gt;

&lt;p&gt;Why am I asking them to pay?&lt;/p&gt;

&lt;p&gt;I sat with that question for two weeks. Every answer felt thin.&lt;/p&gt;

&lt;p&gt;"Because the software costs me money to maintain."&lt;/p&gt;

&lt;p&gt;OK, true, but that user is not loading my support queue. They are not going to email me. They are using the product silently.&lt;/p&gt;

&lt;p&gt;"Because if I let them export for free, they will never upgrade."&lt;/p&gt;

&lt;p&gt;They were never going to upgrade. They do not have a use case for the Commercial license, they are not running paid work through it. The "upgrade" framing is a trick I would be playing on someone who could not fall for it.&lt;/p&gt;

&lt;p&gt;"Because it is the standard model in the category."&lt;/p&gt;

&lt;p&gt;The least convincing answer of all. "Everyone does it" has never been a strong reason to do anything.&lt;/p&gt;

&lt;p&gt;The honest version of the question kept reformulating itself in my head:&lt;/p&gt;

&lt;p&gt;Why should someone pay me if they are not making money from this themselves?&lt;/p&gt;

&lt;p&gt;I did not have a good answer. So I stopped trying to find one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;I removed the project cap. I removed the watermark. I removed all limits. The personal tier of Notch is fully free. No degraded export, no nag screens, no asterisks. You install the app, you hit record, you export your video. Done.&lt;/p&gt;

&lt;p&gt;The Commercial license is still there. It is $49 one-time, with team pricing for 3 and 10 seats. The split is not "free for limited use, paid for everything". It is "free for personal use, paid for revenue-generating use". The line is the purpose of the recording, not the feature set.&lt;/p&gt;

&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A student making a class project: free, forever, with full features.&lt;/li&gt;
&lt;li&gt;A bootcamp graduate recording tutorials for their portfolio: free.&lt;/li&gt;
&lt;li&gt;An OSS maintainer recording a feature demo for a GitHub issue: free.&lt;/li&gt;
&lt;li&gt;A teacher recording an async lesson for their students: free.&lt;/li&gt;
&lt;li&gt;A consultant making client deliverables: Commercial license.&lt;/li&gt;
&lt;li&gt;A SaaS team recording onboarding videos: Commercial license.&lt;/li&gt;
&lt;li&gt;A YouTuber monetizing their channel: Commercial license.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The free tier and the Commercial tier are the same software. The license is what changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The objection I expected
&lt;/h2&gt;

&lt;p&gt;I had a draft of this post that opened with the predictable objection: "But how will you actually make money?"&lt;/p&gt;

&lt;p&gt;I cut it because I think it deserves a real answer, not a defensive setup. Here is the real answer.&lt;/p&gt;

&lt;p&gt;Most non-monetizing users were never going to pay anyway. Walking into a freemium model assuming they are conversion-able is wishful. The honest math: if 95% of free-tier users would never pay regardless of how much you crippled the free tier, your "free tier" was effectively gifting them a worse product than they could have had, and resenting them for not converting.&lt;/p&gt;

&lt;p&gt;Commercial users are different. They have a budget line for tools. They are comparing me to Loom at $18/user/month or Screen Studio at $108/year. A one-time $49, or $29/user for a 10-pack, is the cheapest thing in their procurement queue. They are going to pay.&lt;/p&gt;

&lt;p&gt;Goodwill compounds. The student who got Notch for free for a year, who graduates and goes to a SaaS company that needs internal training videos, that is the person who pitches Notch to their boss. Not because of switching costs, but because they already know it works and trust it. That kind of advocacy does not show up in a conversion funnel, is almost impossible to A/B test, and stacks over years.&lt;/p&gt;

&lt;p&gt;The "real free tier" is a marketing position. It is also a moat. Every competitor who runs the freemium-with-limits playbook is implicitly telling their non-monetizing users "we tolerate you, for now". That is not a feeling people seek out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deeper reason
&lt;/h2&gt;

&lt;p&gt;I will be honest about the part that is not a pricing argument.&lt;/p&gt;

&lt;p&gt;I went into product management years ago because I love watching the results of my work get used. Not "purchased". Used. The thing I find satisfying about shipping is people getting value. If I could only sell products and never see them in someone's hands, I would have stayed in e-commerce.&lt;/p&gt;

&lt;p&gt;Building "another commercial product", one more entry in a category, optimized for revenue capture, was not a strong enough reason for me to spend a year of my evenings on something. It is a thing I could have done with my day job's time instead. The reason I built Notch is that I kept wanting it to exist and nothing else delivered it. The reason I am shipping it is that I want other people to have it too.&lt;/p&gt;

&lt;p&gt;A pricing model that restricts the people most likely to enjoy the product, in order to maximize revenue from the people also most likely to enjoy the product, is solving for the wrong thing.&lt;/p&gt;

&lt;p&gt;The team and Commercial side will pay for itself. I would rather underearn from the model I chose than overcharge through one that fights my own reason for building.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell another maker
&lt;/h2&gt;

&lt;p&gt;Three things, briefly.&lt;/p&gt;

&lt;p&gt;Pricing reveals what you actually think your product is for. If the only way the math works is by limiting the people who would love your product most, the math might be wrong.&lt;/p&gt;

&lt;p&gt;The freemium playbook is the default, not the answer. It works for some categories, usually ones with high marginal cost per user, strong network effects, or genuine paid value the free tier cannot replicate. Most maker tools are none of those.&lt;/p&gt;

&lt;p&gt;Trust your discomfort. I spent two months avoiding writing a pricing page because the model I was about to ship felt wrong. That feeling was data. I should have listened to it sooner.&lt;/p&gt;

&lt;p&gt;If you are curious what the product looks like with this pricing applied: &lt;a href="https://getnotch.co" rel="noopener noreferrer"&gt;Notch is on Mac and Windows&lt;/a&gt;, fully free for personal use, no account, no cloud, no nags. Commercial license is one-time, $49.&lt;/p&gt;

&lt;p&gt;I am happy to argue any of the above in the comments. I think I am right, but I have been wrong about pricing for a living. &lt;/p&gt;

&lt;p&gt;May the force be with you.&lt;/p&gt;

</description>
      <category>startup</category>
      <category>showdev</category>
      <category>discuss</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Evaluated Every AI Chat UI Library in 2026. Here's What I Found (and What I Built)</title>
      <dc:creator>Alexander Lukashov</dc:creator>
      <pubDate>Wed, 18 Mar 2026 18:00:24 +0000</pubDate>
      <link>https://dev.to/alexander_lukashov/i-evaluated-every-ai-chat-ui-library-in-2026-heres-what-i-found-and-what-i-built-4p10</link>
      <guid>https://dev.to/alexander_lukashov/i-evaluated-every-ai-chat-ui-library-in-2026-heres-what-i-found-and-what-i-built-4p10</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React stack → &lt;a href="https://github.com/assistant-ui/assistant-ui" rel="noopener noreferrer"&gt;assistant-ui&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Full agent framework → &lt;a href="https://github.com/CopilotKit/CopilotKit" rel="noopener noreferrer"&gt;CopilotKit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Quick prototype or non-React → &lt;a href="https://github.com/OvidijusParsiunas/deep-chat" rel="noopener noreferrer"&gt;Deep Chat&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Python backend → &lt;a href="https://github.com/Chainlit/chainlit" rel="noopener noreferrer"&gt;Chainlit&lt;/a&gt; (check project status first)&lt;/li&gt;
&lt;li&gt;Multi-framework + composable → &lt;a href="https://loquix.dev" rel="noopener noreferrer"&gt;Loquix&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Conflict of interest:&lt;/strong&gt; I'm the author of Loquix, one of the libraries reviewed here. The independent reviews come first. Loquix gets its own clearly-labeled section at the end, written by me about my own work — treat it accordingly. If you want the quick take: for most teams, &lt;strong&gt;assistant-ui&lt;/strong&gt; (React) or &lt;strong&gt;Deep Chat&lt;/strong&gt; (everything else) is the more pragmatic answer.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Before the reviews: four types of lock-in
&lt;/h2&gt;

&lt;p&gt;After evaluating all of these libraries, the biggest insight wasn't about any specific tool — it was that "lock-in" isn't one thing. There are at least four distinct flavors, and understanding which one you're accepting changes how much it matters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;When it bites&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Framework&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;assistant-ui (React-only)&lt;/td&gt;
&lt;td&gt;When you need to support other frameworks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Architecture/runtime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CopilotKit&lt;/td&gt;
&lt;td&gt;When your agent infrastructure evolves independently of your frontend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ecosystem&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vercel stack&lt;/td&gt;
&lt;td&gt;Creeps up over time; each layer pulls you deeper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API-surface&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Deep Chat&lt;/td&gt;
&lt;td&gt;When your requirements outgrow the component's config options&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Framework lock-in is rarely a problem if your whole team is on React. Architecture lock-in hurts when backend and frontend evolve at different speeds. Ecosystem lock-in you feel slowly — you start with &lt;code&gt;useChat&lt;/code&gt;, six months later you're on Vercel hosting, each step made sense. API-surface lock-in only matters when your requirements outgrow the defaults.&lt;/p&gt;

&lt;p&gt;I'll reference these types throughout the reviews so you can calibrate the tradeoffs for your situation.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "chat UI" actually requires
&lt;/h2&gt;

&lt;p&gt;"Messages going up, input at the bottom" — I thought the same thing. Then I started building. Here's the real scope:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core:&lt;/strong&gt; Token-by-token streaming without UI glitches, markdown + code blocks with syntax highlighting, a composer that auto-grows and handles Shift+Enter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interactive layer:&lt;/strong&gt; File attachments (drag-and-drop + clipboard paste), feedback buttons for RLHF, a stop-generating button (you'll add this at 2 AM after your agent writes a 4,000-word essay about semicolons), model selectors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trust layer:&lt;/strong&gt; Citations, reasoning traces, tool execution logs, cost estimates. Users increasingly expect to see &lt;em&gt;why&lt;/em&gt; the AI said what it said.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The invisible stuff:&lt;/strong&gt; Accessibility (WCAG, keyboard nav), theming that survives a designer, i18n.&lt;/p&gt;

&lt;p&gt;Build all of that from scratch: 2–4 weeks, before a single line of agent logic. That's why this market exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  The libraries
&lt;/h2&gt;

&lt;h3&gt;
  
  
  assistant-ui ⭐ ~7.9k — React, composable, headless — MIT
&lt;/h3&gt;

&lt;p&gt;YC-backed. The library that shows up in every "build a ChatGPT clone" thread.&lt;/p&gt;

&lt;p&gt;Follows the Radix headless pattern — unstyled primitives (&lt;code&gt;Thread&lt;/code&gt;, &lt;code&gt;Composer&lt;/code&gt;, &lt;code&gt;Message&lt;/code&gt;) you compose yourself. State management is thoughtful, streaming is first-class, Vercel AI SDK integration is tight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; React + Next.js teams that want maximum control without implementing the hard parts. If your whole stack is React, this is the safe default — mature ecosystem, responsive maintainers, well-designed composability model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; React-only (framework lock-in). Vue, Svelte, Angular, vanilla JS — not supported. "Headless" also means you do all the design work; getting a polished UI requires assembling many pieces and the learning curve is real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in type:&lt;/strong&gt; Framework.&lt;/p&gt;




&lt;h3&gt;
  
  
  CopilotKit ⭐ ~28.6k — React, agent framework with UI — commercial tiers available
&lt;/h3&gt;

&lt;p&gt;Not just a UI library — an agentic application framework that includes UI components. Drop in &lt;code&gt;&amp;lt;CopilotPortal /&amp;gt;&lt;/code&gt;, get a full copilot experience. Agents can read your app state, call your functions, generate UI dynamically. They co-created AG-UI and partnered with Google on A2UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; Greenfield projects where you're buying into their whole architecture. If you need deep agent-app state sync and generative UI, nothing else comes close.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; If all you need is a good chat interface, this is a flamethrower for a candle. More importantly: it's not just framework lock-in, it's &lt;em&gt;architecture&lt;/em&gt; lock-in. You're adopting their agent execution model, state sync, action system — not just swapping in components. On brownfield projects with existing agent infrastructure, that means rethinking things you've already built.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in type:&lt;/strong&gt; Architecture/runtime (the heaviest kind).&lt;/p&gt;




&lt;h3&gt;
  
  
  Vercel AI SDK + AI Elements — React, hooks + growing component layer — MIT
&lt;/h3&gt;

&lt;p&gt;The AI SDK is everywhere: model-provider abstraction with &lt;code&gt;useChat&lt;/code&gt; / &lt;code&gt;useCompletion&lt;/code&gt; hooks. Not a UI library at its core, but Vercel has been expanding into actual components with &lt;strong&gt;AI Elements&lt;/strong&gt; — composable React components on top of the SDK.&lt;/p&gt;

&lt;p&gt;Worth mentioning separately: the &lt;strong&gt;Vercel AI Chatbot&lt;/strong&gt; is the official open-source reference implementation built on this stack. Many teams use it as a starting point or benchmark for what a production chat UI looks like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; Anyone already in the Vercel ecosystem. AI Elements is worth evaluating alongside assistant-ui if you're on Next.js.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; The Vercel stack has gravitational pull. You start with &lt;code&gt;useChat&lt;/code&gt;. Six months later you're on Vercel hosting. Each step made sense individually — the cumulative effect is ecosystem lock-in that's softer than CopilotKit's but real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in type:&lt;/strong&gt; Ecosystem.&lt;/p&gt;




&lt;h3&gt;
  
  
  TanStack AI — alpha, framework-agnostic hooks — MIT
&lt;/h3&gt;

&lt;p&gt;TanStack — the team behind React Query, React Router, React Table — launched their AI toolkit. Framework-agnostic core, adapters for React, Solid, vanilla JS. Headless, type-safe.&lt;/p&gt;

&lt;p&gt;Important context: this is alpha from a team with a strong track record of shipping production-grade, widely-adopted tools. That's different from "alpha from unknowns." If it matures the way React Query did, this will be a serious foundation layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; Nobody in production yet. Watch this space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; No pre-built UI components — hooks and utilities, not a chat interface. Vue and Angular adapters aren't there. Not ready for production today.&lt;/p&gt;




&lt;h3&gt;
  
  
  Google A2UI — declarative format for agent-generated UI
&lt;/h3&gt;

&lt;p&gt;Agents send JSON describing which components to render; your frontend renders them from a trusted catalog. No executable code from the agent — no XSS nightmares. Framework-agnostic by design. Ships initial renderers for Lit, Angular, Flutter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; Teams building agent-generated dynamic UIs. The CopilotKit integration is worth watching, and this is one of the more interesting directions for generative UI / RSC-style patterns outside the React ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; It's an infrastructure layer, not a component library. Not something you'd drop into a project and ship today.&lt;/p&gt;




&lt;h3&gt;
  
  
  Deep Chat ⭐ ~3.3k — Web Component, framework-agnostic — MIT
&lt;/h3&gt;

&lt;p&gt;The one that made me sit up. A single Web Component for AI chat — framework-agnostic, built-in connections to OpenAI, HuggingFace, Cohere, Azure, Stability AI, AssemblyAI out of the box. Ships with speech-to-text, text-to-speech, file uploads, image handling.&lt;/p&gt;

&lt;p&gt;Time-to-working-chat: under 10 minutes. No backend glue required to get started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; Prototypes, internal tools, non-React projects, or anywhere chat is a feature rather than the core experience. Best option when you need something working fast that doesn't care about your frontend framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; One component with configuration options, not a composable system. When you need to restructure layout, inject custom UI between messages, or add domain-specific controls — you're working within what the property API can express. Same tradeoff as any "convention over composition" approach: faster start, less flexibility when requirements diverge from defaults.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in type:&lt;/strong&gt; API-surface.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chainlit ⭐ ~11.4k — Python-first, full-stack
&lt;/h3&gt;

&lt;p&gt;The go-to for Python developers who wanted a chat UI without touching JavaScript. Write your agent in Python, get a polished web UI with reasoning steps, file uploads, markdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caveat:&lt;/strong&gt; The original team stepped back from active development in May 2025. Community maintainers have picked it up. Worth verifying current project status before building on it.&lt;/p&gt;




&lt;h2&gt;
  
  
  One more thing: "Chat UI" is becoming "Agent UI"
&lt;/h2&gt;

&lt;p&gt;Streaming is solved. Every library handles it well now — it's no longer a differentiator.&lt;/p&gt;

&lt;p&gt;What's not solved is composition for agent-specific UI patterns: approval flows, reasoning traces, tool execution displays, cost transparency. These are becoming table stakes, not features. Libraries that only solve the messaging problem will feel incomplete within a year.&lt;/p&gt;

&lt;p&gt;Generative UI (agents that emit UI, not just text) is the next frontier — watch A2UI + CopilotKit here, and the RSC patterns in the Vercel ecosystem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Loquix — what I built, and why you probably shouldn't use it yet
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Repeat disclosure:&lt;/strong&gt; this is my library. Everything below is me describing my own work. The same "where it hurts" standard I applied above applies here too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;None of the options above fit my specific situation: a product where the customer's frontend framework isn't fixed, and I wanted composable building blocks rather than a monolithic widget or agent runtime.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/loquix-dev/loquix" rel="noopener noreferrer"&gt;Loquix&lt;/a&gt; — a Web Components library on Lit, specifically for AI chat interfaces. (&lt;code&gt;npm install @loquix/core&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fopa06wpaz75079x6cxax.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fopa06wpaz75079x6cxax.gif" alt="Loquix demo" width="480" height="648"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The approach
&lt;/h3&gt;

&lt;p&gt;35 individual Web Components that compose like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;loquix-chat-container&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;loquix-chat-header&lt;/span&gt; &lt;span class="na"&gt;slot=&lt;/span&gt;&lt;span class="s"&gt;"header"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loquix-model-selector&lt;/span&gt; &lt;span class="na"&gt;slot=&lt;/span&gt;&lt;span class="s"&gt;"actions"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/loquix-chat-header&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;loquix-message-list&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loquix-message-item&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"assistant"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;loquix-message-content&lt;/span&gt; &lt;span class="na"&gt;streaming&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;loquix-message-actions&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;loquix-action-copy&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;loquix-action-feedback&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/loquix-message-actions&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/loquix-message-item&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/loquix-message-list&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;loquix-chat-composer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loquix-prompt-input&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loquix-drop-zone&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/loquix-chat-composer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/loquix-chat-container&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verbose, yes. But when your designer says "move feedback above the message" — you move the component. No prop drilling, no checking if &lt;code&gt;feedbackPosition&lt;/code&gt; is a valid prop value.&lt;/p&gt;

&lt;p&gt;Backend integration is a type-only interface with no runtime dependency on any specific provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AgentProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;SendOptions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReadableStream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works in React, Vue, Svelte, Angular, vanilla JS. &lt;code&gt;@loquix/react&lt;/code&gt; ships with proper wrappers for React's synthetic event system. Vue/Svelte dedicated packages are planned, not shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in type:&lt;/strong&gt; Platform (Web Components/Shadow DOM model, Lit if you extend components deeply) — just at the browser standards level rather than the library level.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it hurts — same standard as above
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;No production deployments.&lt;/strong&gt; I can't point you to a production app using Loquix. That's a significant unknown that none of the other libraries in this review share.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small community.&lt;/strong&gt; No Stack Overflow answers. You'll be reading source code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incomplete roadmap.&lt;/strong&gt; Reasoning traces, citations, cost estimates — Phase 4 and 5 — still in development. If you need those today, build them yourself or wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lit learning curve.&lt;/strong&gt; Consuming is easy. Deep customization requires understanding Lit's reactive model and Shadow DOM. React developers find this counterintuitive at first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No built-in provider integrations.&lt;/strong&gt; Deep Chat connects to OpenAI in 10 minutes. assistant-ui integrates with Vercel AI SDK out of the box. Loquix gives you a TypeScript interface and says "implement it." Better for experienced teams; more friction for everyone else — the same critique I'd apply to any library that takes this approach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concrete example where assistant-ui wins:&lt;/strong&gt; chat UI integrating with Vercel AI SDK's &lt;code&gt;useChat&lt;/code&gt;, rendering generative UI streamed from server, thread persistence, all in Next.js. With assistant-ui: well-documented, battle-tested. With Loquix: you'd be wiring streaming yourself, no RSC integration (Web Components and React Server Components don't mix naturally), and you'd be the first person to try this combination in production. Hard to justify.&lt;/p&gt;

&lt;p&gt;If your situation is specifically "multi-framework team that wants composable components and is willing to be an early adopter" — evaluate it. Otherwise, the options above are more pragmatic.&lt;/p&gt;




&lt;h2&gt;
  
  
  The decision table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;th&gt;Maturity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;React + Next.js&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;assistant-ui&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Production-ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full agent framework, generative UI&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;CopilotKit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Production-ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider abstraction + React&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Vercel AI SDK + AI Elements&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Production-ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quick prototype, non-React, embedded widget&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Deep Chat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Production-ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python-first backend&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Chainlit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⚠️ Check project status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-framework + composable, early adopter&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://github.com/loquix-dev/loquix" rel="noopener noreferrer"&gt;Loquix&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;🚧 No production deployments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent-generated dynamic UI&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;A2UI + CopilotKit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;👀 Watch this space&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Greenfield vs. brownfield:&lt;/strong&gt; heavier frameworks (CopilotKit, Vercel full stack) accelerate you on greenfield when you're choosing everything fresh. Thinner UI layers (assistant-ui, Deep Chat, Loquix) cause less friction on brownfield where the architecture already exists.&lt;/p&gt;




&lt;p&gt;The days of spending weeks building chat UI from scratch are over. Pick a library, commit, and spend your engineering time on the thing that actually differentiates your product.&lt;/p&gt;

&lt;p&gt;If I missed a library you've used in production, drop it in the comments.&lt;/p&gt;

&lt;p&gt;Unless you enjoy writing auto-scroll logic at 2 AM. In which case, I have a &lt;code&gt;useEffect&lt;/code&gt; cleanup function I'd like to sell you.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
      <category>webcomponents</category>
    </item>
  </channel>
</rss>
