<?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: Incultnito LLC</title>
    <description>The latest articles on DEV Community by Incultnito LLC (@incultnito_llc).</description>
    <link>https://dev.to/incultnito_llc</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.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F13260%2Fab2da49d-4cbd-4883-8c4b-6f4c8dc8fd1d.png</url>
      <title>DEV Community: Incultnito LLC</title>
      <link>https://dev.to/incultnito_llc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/incultnito_llc"/>
    <language>en</language>
    <item>
      <title>Four pgvector patterns that kept our RAG SaaS on one Postgres</title>
      <dc:creator>pengspirit</dc:creator>
      <pubDate>Fri, 12 Jun 2026 08:17:45 +0000</pubDate>
      <link>https://dev.to/incultnito_llc/four-pgvector-patterns-that-kept-our-rag-saas-on-one-postgres-230h</link>
      <guid>https://dev.to/incultnito_llc/four-pgvector-patterns-that-kept-our-rag-saas-on-one-postgres-230h</guid>
      <description>&lt;p&gt;Most RAG tutorials stop at &lt;code&gt;embedding &amp;lt;=&amp;gt; query&lt;/code&gt;. They show you the operator, return five rows, and call it retrieval. Then you ship it, a second customer signs up, and you discover the four things the tutorial skipped: indexing on a column that's half-NULL, the distance-vs-similarity sign flip, the dimension lock-in, and the function that quietly bypasses your tenant isolation.&lt;/p&gt;

&lt;p&gt;I run a Discord-native Company Brain. Teams &lt;code&gt;/save&lt;/code&gt; docs, links, and PDFs; &lt;code&gt;/ask&lt;/code&gt; returns a grounded, cited answer. The whole vector store is &lt;strong&gt;one Supabase Postgres with pgvector&lt;/strong&gt; — no Pinecone, no second system to bill and reconcile. Here are four patterns that made that survive contact with real workspaces.&lt;/p&gt;

&lt;p&gt;## The problem: a vector column is not a vector store&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;vector(1536)&lt;/code&gt; column gives you storage and a distance operator. It does not give you fast search, correct ranking, dimension discipline, or multi-tenant safety. Those are four separate decisions, and getting any one wrong shows up as a production bug, not a compile error.&lt;/p&gt;

&lt;p&gt;Our &lt;code&gt;artifacts&lt;/code&gt; table holds every chunk a workspace has ingested. The relevant columns:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
sql
  CREATE TABLE artifacts (
    id           uuid    PRIMARY KEY DEFAULT gen_random_uuid(),
    workspace_id uuid    NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
    content      text    NOT NULL,
    content_hash text    NOT NULL,        -- sha256, short-circuits re-embedding
    metadata     jsonb   NOT NULL DEFAULT '{}'::jsonb,
    -- 1536 dims = OpenAI text-embedding-3-small.
    embedding    vector(1536),            -- NULLABLE on purpose. See pattern 1.
    created_at   timestamptz NOT NULL DEFAULT now(),
    UNIQUE (workspace_id, source_type, external_id)
  );

  Note that embedding is nullable. Artifacts arrive un-embedded — the web service writes the row instantly, a worker embeds it async on a */15 cron. That single nullable column drives the first pattern.

  Pattern 1: Index only the rows that have a vector

  The naive HNSW index covers the whole column. But half our rows are NULL at any given moment during backfill, and building HNSW graph edges for NULL rows is wasted work and wasted index size.

  The fix is a partial index with a WHERE predicate:

  -- Partial HNSW index: only index rows that actually have an embedding.
  -- Keeps the index small during async backfill and skips HNSW build cost
  -- on NULL rows entirely.
  CREATE INDEX artifacts_embedding_hnsw_idx
    ON artifacts
    USING hnsw (embedding vector_cosine_ops)
    WHERE embedding IS NOT NULL;

  Two choices worth defending:

  - HNSW over IVFFlat. IVFFlat needs training data to build its lists — you have to populate the table first, then build the index, and rebuild as the distribution shifts. HNSW builds incrementally as rows arrive. For a product where every workspace starts at zero artifacts and grows continuously, "no training step, no rebuild" wins. We left m and ef_construction at pgvector defaults and wrote a note to tune them once we have real latency data — premature index tuning is just a guess with extra steps.
  - vector_cosine_ops, not the default. The operator class in the index must match the distance operator your query uses. Index on vector_cosine_ops, query with &amp;lt;=&amp;gt; (cosine distance). Mismatch them and Postgres silently does a sequential scan — correct answers, terrible latency, no error to tell you why.

  Pattern 2: The sign flip — distance is not similarity
  pgvector's &amp;lt;=&amp;gt; returns cosine distance: 0 is identical, 2 is opposite. Humans, dashboards, and threshold configs think in similarity: 1 is identical, 0 is unrelated. The conversion is similarity = 1 - distance, and you have to apply it consistently in three places or your ranking inverts.

  Here's the actual retrieval RPC. Watch where &amp;lt;=&amp;gt; appears raw (ordering) versus converted (filtering and output):

  CREATE OR REPLACE FUNCTION match_artifacts(
    p_workspace_id  uuid,
    query_embedding vector(1536),
    match_count     int   DEFAULT 5,
    min_similarity  float DEFAULT 0.15
  )
  RETURNS TABLE (id uuid, content text, similarity float)
  LANGUAGE sql
  SECURITY INVOKER                      -- critical. See pattern 4.
  AS $$
    SELECT
      a.id,
      a.content,
      1 - (a.embedding &amp;lt;=&amp;gt; query_embedding) AS similarity   -- distance -&amp;gt; similarity
    FROM artifacts a
    WHERE a.workspace_id = p_workspace_id
      AND a.embedding IS NOT NULL
      AND 1 - (a.embedding &amp;lt;=&amp;gt; query_embedding) &amp;gt;= min_similarity  -- filter in similarity space
    ORDER BY a.embedding &amp;lt;=&amp;gt; query_embedding                       -- order in DISTANCE space (ASC)
    LIMIT match_count;
  $$;

  The ORDER BY stays in distance space and sorts ascending — smallest distance first — because that's the direction the HNSW index understands. Flip it to ORDER BY similarity DESC and you get the same logical result but you've handed the planner an expression it can't satisfy from the index, so it sorts in memory after a scan. Order by the raw operator; convert only for the human-facing columns.

  Our retrieval defaults — match_count = 5, min_similarity = 0.15 — came out of tuning against our own corpus, not a paper. Higher k bloats the model's context window without lifting answer quality; a lower threshold lets junk through and the model starts hedging. They're defaults, not laws: the RPC takes both as parameters so we can override per workspace.

  Pattern 3: Dimensions are a one-way door — plan the migration before you need it

  vector(1536) is a hard constraint. The number 1536 is OpenAI's text-embedding-3-small. If you decide to swap models, a different dimension count means the column type no longer fits and every existing embedding is now garbage against the new query vectors.

  We evaluated text-embedding-3-large (3072-dim) in week two. The numbers:

  ┌──────────────────────────┬─────────────────┬───────────────┐
  │           Knob           │ -small (chosen) │    -large     │
  ├──────────────────────────┼─────────────────┼───────────────┤
  │ Dimensions               │ 1536            │ 3072          │
  ├──────────────────────────┼─────────────────┼───────────────┤
  │ Top-5 recall (our eval)  │ baseline        │ ~3 pts higher │
  ├──────────────────────────┼─────────────────┼───────────────┤
  │ Cost per token           │ 1×              │ 6×            │
  ├──────────────────────────┼─────────────────┼───────────────┤
  │ pgvector storage per row │ 1×              │ 2×            │
  └──────────────────────────┴─────────────────┴───────────────┘

  Three points of recall for six times the cost and double the storage did not clear the bar at our scale. Tuning min_similarity lifted precision more cheaply than the extra dimensions did. But the real lesson is the migration rule we wrote down so a future me doesn't fight the column type at 2am:

  ▎ When we change embedding models, the new vector goes in a new column (embedding_v2 vector(3072)), backfilled and dual-read behind a flag — never an in-place ALTER of the 
  ▎ existing column.

  Adding a column lets old and new embeddings coexist while you backfill millions of rows and verify recall didn't regress. Altering the column in place takes a write lock on the
  whole table and gives you no rollback. Pick the boring migration.

  Pattern 4: The function that bypasses your tenancy — SECURITY INVOKER, always

  This one nearly made me quit for the day. Our entire multi-tenant model is Row Level Security keyed on workspace_id: a policy on artifacts means a query physically cannot return
  another tenant's rows. Airtight — except for a function declared SECURITY DEFINER, which runs with the definer's privileges and skips RLS entirely.

  A vector-search RPC is exactly the kind of function people reflexively mark SECURITY DEFINER (it's calling into internals, feels like it should be privileged). Do that, and match_artifacts happily returns chunks across workspace boundaries even though RLS is enabled on the table. The leak doesn't throw — it just quietly serves the wrong tenant's data.

  Two defenses, both in the RPC above:

  1. SECURITY INVOKER — the function runs as the caller, so RLS policies apply inside it exactly as they would on a direct query.
  2. An explicit WHERE a.workspace_id = p_workspace_id predicate — belt and suspenders. RLS is the wall; the predicate is the lock. If a future migration ever fumbles a policy, the predicate still scopes the result.

  And because the only caller is the worker (holding a service-role key on a trusted server), we revoke the function from public roles entirely:

  -- Only the service-role worker needs this. Anon/authenticated never call it.
  REVOKE EXECUTE ON FUNCTION match_artifacts FROM anon, authenticated;

  The TypeScript side stays boring, which is the point — all the safety lives in the database:

  const { data: matches } = await supabase.rpc("match_artifacts", {
    p_workspace_id: workspaceId,     // scoped by the caller, enforced by RLS + predicate
    query_embedding: queryVector,    // 1536-dim, same model as ingest
    match_count: 5,
    min_similarity: 0.15,
  });

  Write the cross-tenant leak test before the retrieval feature, not after. I wrote it after, which is how I learned the difference between DEFINER and INVOKER the expensive way.

  Takeaways

  - Partial-index your vector column when embeddings arrive async — don't pay HNSW cost on NULL rows.
  - HNSW when rows stream in continuously (no training step); match the operator class to your distance operator or you'll silently seq-scan.
  - Convert distance to similarity only for filtering and output — keep ORDER BY in raw distance space so the index does the sorting.
  - Dimensions are immutable: new model means new column, dual-read, backfill — never in-place ALTER.
  - SECURITY INVOKER plus an explicit tenant predicate. A DEFINER vector RPC is a cross-tenant leak with a clean stack trace.

  Keeping embeddings inside the same Postgres that enforces RLS is what makes one operator (me) able to run multi-tenant RAG without a second system to secure. That's the bet behind Acortia (https://acortia.com) — the brain lives where the tenancy is already enforced.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>postgres</category>
      <category>rag</category>
      <category>ai</category>
      <category>typescript</category>
    </item>
    <item>
      <title>How I built a RAG-grounded Discord brain in 5 weeks (solo, ESL, no funding)</title>
      <dc:creator>pengspirit</dc:creator>
      <pubDate>Wed, 03 Jun 2026 06:19:18 +0000</pubDate>
      <link>https://dev.to/incultnito_llc/how-i-built-a-rag-grounded-discord-brain-in-5-weeks-solo-esl-no-funding-1fgh</link>
      <guid>https://dev.to/incultnito_llc/how-i-built-a-rag-grounded-discord-brain-in-5-weeks-solo-esl-no-funding-1fgh</guid>
      <description>&lt;h2&gt;
  
  
  Day 14. The fourth time.
&lt;/h2&gt;

&lt;p&gt;A user in our Discord asked, for the fourth time that week, the same question. Same wording, almost. The first three answers were buried somewhere in a thread, a pinned message, and a Notion page nobody bookmarked. A mod typed it out again. I watched it happen, opened Cursor, and started typing.&lt;/p&gt;

&lt;p&gt;That's the moment Acortia became a product instead of a side note.&lt;/p&gt;

&lt;p&gt;I'm Peng. Solo founder. Non-native English speaker. ESL teacher in Taipei by day, building backend software at night and on weekends. No funding. No team. No accelerator yet — YC F26 application is in. Five weeks ago I committed to building &lt;strong&gt;Acortia&lt;/strong&gt;: a Discord-native Company Brain that answers &lt;code&gt;/ask &amp;lt;q&amp;gt;&lt;/code&gt; with a grounded, cited answer pulled from whatever the server has &lt;code&gt;/save&lt;/code&gt;d. $99/month. Mid-June launch.&lt;/p&gt;

&lt;p&gt;This is the build log. Real numbers, real bugs, real tradeoffs. No hype.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem, stated honestly
&lt;/h2&gt;

&lt;p&gt;Discord communities accumulate institutional knowledge the way a cluttered desk accumulates receipts: faster than anyone can file it. Threads scroll past. Pinned messages cap at 50. Search is keyword-based and stops at the channel boundary. New members ask questions that were answered six months ago in a thread that's now archived.&lt;/p&gt;

&lt;p&gt;The cost isn't dramatic — it's grinding. Mods burn out re-answering. Founders re-explain pricing. Engineers re-link the same architecture diagram. Knowledge exists; it just isn't retrievable.&lt;/p&gt;

&lt;p&gt;I looked at the existing options. Notion + Discord bots: too much manual upkeep. Generic AI chatbots: hallucinate confidently with no source. Custom in-house RAG: out of reach for the average community. The gap was a thin, opinionated tool that lived where the conversation already happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape of the fix
&lt;/h2&gt;

&lt;p&gt;Acortia is three slash commands and a cron job.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/save &amp;lt;url&amp;gt;&lt;/code&gt; — ingest a doc, a thread, a webpage, a PDF. Worker chunks it, embeds it, stores it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/ask &amp;lt;q&amp;gt;&lt;/code&gt; — retrieve top-k chunks via cosine similarity, ground a model response in them, return the answer with &lt;strong&gt;inline citations&lt;/strong&gt; to the source artifacts.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/sources&lt;/code&gt; — list what the server has ingested. Audit trail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Install: OAuth the bot, click through to &lt;code&gt;api.acortia.com/install&lt;/code&gt;, claim the workspace via magic-link email. Thirty seconds end-to-end if the operator already has Discord admin.&lt;/p&gt;

&lt;p&gt;That's the whole product surface. Everything else is plumbing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture, in three layers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Discord is the surface.&lt;/strong&gt; Three slash commands registered globally, one OAuth flow, webhook-style interaction endpoints handled by the Render web service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase is the brain.&lt;/strong&gt; Seven tables. Postgres with the &lt;code&gt;pgvector&lt;/code&gt; extension. Row Level Security keyed to &lt;code&gt;workspace_id&lt;/code&gt;. A single SQL RPC, &lt;code&gt;match_artifacts&lt;/code&gt;, does the vector search. RLS means a misrouted query physically cannot return another workspace's data — the database itself enforces tenancy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Render is the muscle.&lt;/strong&gt; A web service handles interactive Discord requests with a &amp;lt; 3s deadline. A worker process handles the slow path: fetch URL, extract text (PDF connector for &lt;code&gt;application/pdf&lt;/code&gt;, readability-style extractor for HTML), chunk, embed, write. A &lt;code&gt;*/15&lt;/code&gt; cron sweeps queued ingest jobs and re-runs anything that timed out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe is the till.&lt;/strong&gt; Checkout session for the $99/mo plan, webhook handler with idempotency (every event ID is upserted into &lt;code&gt;stripe_events_seen&lt;/code&gt; before any side effect runs), portal link for self-serve management. Promo codes managed in the Stripe dashboard.&lt;/p&gt;

&lt;p&gt;Here's the SQL signature of the only RPC the app calls for retrieval. Stylized — the live function has more telemetry, but this is the shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- match_artifacts: cosine similarity search scoped by workspace&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;match_artifacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;workspace_id_input&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;match_count&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;min_similarity&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;artifact_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;chunk_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;source_url&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;similarity&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;stable&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;artifact_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;chunk_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;artifacts&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;artifact_id&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;workspace_id_input&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;min_similarity&lt;/span&gt;
  &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;
  &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="n"&gt;match_count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two numbers in there worth naming: &lt;code&gt;match_count = 5&lt;/code&gt; and &lt;code&gt;min_similarity = 0.15&lt;/code&gt;. I tuned both empirically against my own corpus. Higher k bloats the context window without lifting answer quality; lower threshold lets junk through and the model hedges. Lower k makes confident answers brittle when the corpus is sparse. These are the knobs you'll want to revisit per-customer in v2.&lt;/p&gt;




&lt;h2&gt;
  
  
  A slash command, end to end
&lt;/h2&gt;

&lt;p&gt;Here's &lt;code&gt;/ask&lt;/code&gt;, sanitized and stylized. The real handler has more error wrapping and a deferred-response pattern for Discord's 3-second deadline, but the spine looks like this:&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="c1"&gt;// apps/web/src/routes/interactions/ask.ts (illustrative)&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;embed&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="s2"&gt;../../lib/embed&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;supabase&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="s2"&gt;../../lib/supabase&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;groundAnswer&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="s2"&gt;../../lib/llm&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;handleAsk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;interaction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DiscordInteraction&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;question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;interaction&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;options&lt;/span&gt;&lt;span class="p"&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;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&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;workspaceId&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;resolveWorkspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;interaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;guild_id&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;queryEmbedding&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;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&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;matches&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;match_artifacts&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;query_embedding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;queryEmbedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;workspace_id_input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;match_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;min_similarity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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;matches&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;interaction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No grounded sources found. Try `/save` first.&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;answer&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;groundAnswer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;logQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// queries.metadata&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;interaction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;formatWithCitations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;matches&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;The &lt;code&gt;logQuery&lt;/code&gt; call writes to &lt;code&gt;queries.metadata&lt;/code&gt; — a JSON column that captures which artifacts were retrieved, the similarity scores, latency, and the model used. Telemetry isn't an afterthought; it's the only way to tell, six weeks in, whether the threshold of 0.15 is still right for a given customer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three decisions I'd defend at a YC interview
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. pgvector over Pinecone
&lt;/h3&gt;

&lt;p&gt;Pinecone is excellent. It's also a second system to bill, monitor, and reconcile RLS against. Acortia's whole tenancy model is &lt;code&gt;workspace_id&lt;/code&gt; on every table. If embeddings live in a separate vector DB, I have to re-implement multi-tenant isolation there and trust two systems instead of one.&lt;/p&gt;

&lt;p&gt;pgvector keeps embeddings inside the same Postgres that enforces RLS. The retrieval call is a single RPC. Cost at MVP scale: included in Supabase free tier. The day I outgrow it, the migration to a dedicated vector DB is a few hours, not a rewrite.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Magic-link claim over OAuth-only
&lt;/h3&gt;

&lt;p&gt;Discord OAuth tells me who installed the bot. It does not tell me which &lt;strong&gt;email&lt;/strong&gt; owns the workspace for billing. I needed a second factor: a magic link sent to the operator's email so the Stripe Checkout, the invoice, and the workspace ownership all land on the same identity.&lt;/p&gt;

&lt;p&gt;The decision inside that decision was implicit-flow vs PKCE for the magic-link callback. I went with implicit. PKCE is more secure on paper, but it requires client-side code verifier storage, which on Discord's embedded browser context is fragile. Implicit + short-lived (10 min) one-time codes + server-side verification gave me a flow that worked first try on iOS Discord, Android Discord, and desktop. The tradeoff: implicit is theoretically replayable in the 10-minute window. Mitigation: one-time-use enforced server-side, codes invalidated on first verification.&lt;/p&gt;

&lt;p&gt;I'll revisit PKCE in v2 when I have time to test the embedded-browser edge cases properly.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Render over Vercel
&lt;/h3&gt;

&lt;p&gt;Vercel is faster to ship for stateless routes. Acortia is not stateless. The ingest pipeline runs longer than any serverless function's hard timeout — PDFs in particular. I needed a long-running worker process and a cron. Render gives me both with one config file and one bill. Web + worker + cron on Render hobby tier costs less than a sandwich per month at MVP scale.&lt;/p&gt;

&lt;p&gt;The day I need autoscale across regions, I'll consider Fly. Not before.&lt;/p&gt;




&lt;h2&gt;
  
  
  What broke: the workspace claim race
&lt;/h2&gt;

&lt;p&gt;Day 20. A test user installed Acortia in two Discord servers using the same email, within about ninety seconds of each other. Both installs triggered a workspace-claim flow. Both wrote to the &lt;code&gt;workspaces&lt;/code&gt; table. The second write silently overwrote the first install's billing pointer. The user ended up with one Stripe customer and two Discord servers, but only one of the servers was correctly linked.&lt;/p&gt;

&lt;p&gt;The bug had two causes braided together. The naive implementation was:&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="c1"&gt;// Buggy original — two installs collide&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;workspaces&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guild_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;guildId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maybeSingle&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;existing&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;workspaces&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;existing&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;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;workspaces&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Classic check-then-act. Two concurrent claims both saw &lt;code&gt;existing.data === null&lt;/code&gt;, both ran &lt;code&gt;insert&lt;/code&gt;, the unique constraint caught one and the other won the race. The losing install thought it succeeded because the response came from a different row.&lt;/p&gt;

&lt;p&gt;The fix was atomic upsert plus moving email collection to claim time, not install time:&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="c1"&gt;// Day-20 fix — atomic, idempotent&lt;/span&gt;
&lt;span class="kd"&gt;const&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;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;workspaces&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="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;guild_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;guildId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;claim_email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// email collected later via magic link&lt;/span&gt;
      &lt;span class="na"&gt;claim_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;generateToken&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;claim_expires_at&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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="na"&gt;onConflict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guild_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ignoreDuplicates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The atomic upsert means the database decides the winner. The deferred email means the second install doesn't even try to write the email column until the magic link is verified, which by then has a unique session token to disambiguate. I also added a trigger to fail-loud if &lt;code&gt;claim_email&lt;/code&gt; ever gets overwritten on a row that already has one — defense in depth.&lt;/p&gt;

&lt;p&gt;Stripe webhooks got the same treatment because they always should:&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="c1"&gt;// Webhook idempotency — check before any side effect&lt;/span&gt;
&lt;span class="kd"&gt;const&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;seen&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe_events_seen&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;event_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maybeSingle&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;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&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;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe_events_seen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleStripeEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// safe to run exactly once&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Idempotent webhooks are non-negotiable. Stripe will retry. You will get duplicates. Plan for it on Day 1, not Day 30.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I didn't ship
&lt;/h2&gt;

&lt;p&gt;Three things were on the board and got cut. Each cut was deliberate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slack adapter.&lt;/strong&gt; I scaffolded a platform-adapter abstraction on Day 8 — the idea was that &lt;code&gt;/save&lt;/code&gt; and &lt;code&gt;/ask&lt;/code&gt; would be platform-agnostic and Slack would be a second surface. The scaffolding is in the repo. I did not build the Slack OAuth flow, slash command registration, or interaction handler. Reason: Slack outreach pre-launch was zero signal. Discord operators were actively asking for the tool. Building Slack would have cost a week and shipped a feature for a customer I didn't have. Parked until live revenue justifies it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notion connector.&lt;/strong&gt; Considered. Killed. The use case I imagined — pull Notion pages as artifacts — is well-served by users copy-pasting URLs into &lt;code&gt;/save&lt;/code&gt;. The MCP route through Claude Desktop is enough for the operator's personal workflow. A first-party Notion connector adds OAuth, page-permission edge cases, and a separate sync cron. Not worth the complexity at MVP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pipedream MCP custom server.&lt;/strong&gt; I spent a few hours wiring Pipedream as a generic connector tier. Backend was healthy, auth worked, but the abstraction was leaking into the slash-command UX. I cut it and routed power-user workflows through Claude Desktop's MCP instead. Acortia stays focused. Operators who want orchestration use Claude Desktop and call Acortia as a tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Telemetry first. I added &lt;code&gt;queries.metadata&lt;/code&gt; on Day 6, which was correct, but I didn't build a dashboard around it until Week 4. For the first three weeks I was debugging retrieval quality by reading raw Postgres rows. A 30-minute Metabase dashboard would have saved hours of squinting. If you're building RAG: instrument retrieval before you instrument anything else. You can't tune what you can't see.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Mid-June 2026 launch. Soft-live now for beta operators.&lt;/p&gt;

&lt;p&gt;Install: &lt;strong&gt;api.acortia.com/install&lt;/strong&gt;&lt;br&gt;
Domain: &lt;strong&gt;acortia.com&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Promo for readers of this post: &lt;code&gt;BETA-FREE-30D&lt;/code&gt; — 100% off the first month, 10 redemptions, expires 2026-06-30 23:59 UTC. After that the price is $99/month flat. No per-seat. No usage tier. One Discord server, one bill.&lt;/p&gt;

&lt;p&gt;If you operate a Discord community, run a developer relations team, or moderate a paid creator server: this was built for you. If you don't, the architecture above is open notes — steal whatever's useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  Footer: the founder context
&lt;/h2&gt;

&lt;p&gt;I'm in Taipei. I teach English to fund this build. I am not a native English speaker and I rewrite half of what I publish three times before it reads cleanly. Every line of Acortia was written between lesson plans and weekend mornings. No team. No accelerator yet. No outside capital.&lt;/p&gt;

&lt;p&gt;What I'm proving with this build: a solo non-US founder can ship a credible B2B SaaS product end-to-end — auth, billing, RAG, multi-tenant data isolation, idempotent webhooks, a real cron pipeline — in five weeks of nights-and-weekends time, on a stack that costs less than a streaming subscription to run.&lt;/p&gt;

&lt;p&gt;If that's interesting to you, the install link is above. If you want to talk shop, I'm on Discord and X under the same handle.&lt;/p&gt;

&lt;p&gt;Brief. Concept. Preview. Ship.&lt;/p&gt;

</description>
      <category>discord</category>
      <category>rag</category>
      <category>supabase</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>6 of 6 official MCP servers cluster at 56–60/100 on schema-description density</title>
      <dc:creator>pengspirit</dc:creator>
      <pubDate>Wed, 27 May 2026 07:10:39 +0000</pubDate>
      <link>https://dev.to/incultnito_llc/6-of-6-official-mcp-servers-cluster-at-56-60100-on-schema-description-density-4f9c</link>
      <guid>https://dev.to/incultnito_llc/6-of-6-official-mcp-servers-cluster-at-56-60100-on-schema-description-density-4f9c</guid>
      <description>&lt;p&gt;After ten days of running the v1.1.0 publishability rubric against every MCP server I can find on npm under the official &lt;code&gt;@modelcontextprotocol&lt;/code&gt; scope, the cluster pattern is now&lt;br&gt;
  hard to ignore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6 of 6 official Anthropic-shipped MCP servers score 56–60/100 on the v1.1.0 publishability composite.&lt;/strong&gt; The cap that fires is the same axis every time: &lt;code&gt;description-five-axis&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;| Server | Composite | Protocol | Edge cases | Publish | Per-tool axis avg | Cap |&lt;br&gt;
  |---|---:|---:|---:|---:|---:|---|&lt;br&gt;
  | &lt;code&gt;server-sequential-thinking&lt;/code&gt; | 60 | 100 | 100 | 20 | n/a (single tool) | &lt;code&gt;description-five-axis&lt;/code&gt; |&lt;br&gt;
  | &lt;code&gt;server-memory&lt;/code&gt; | 60 | 100 | 85 | 50 | 1.00 / 5 | &lt;code&gt;description-five-axis&lt;/code&gt; |&lt;br&gt;
  | &lt;code&gt;server-everything&lt;/code&gt; | 60 | 100 | 94 | 20 | 0.55 / 5 | &lt;code&gt;description-five-axis&lt;/code&gt; |&lt;br&gt;
  | &lt;code&gt;server-filesystem&lt;/code&gt; | 60 | 100 | 57 | 50 | 0.88 / 5 | &lt;code&gt;description-five-axis&lt;/code&gt; |&lt;br&gt;
  | &lt;code&gt;server-github&lt;/code&gt; (legacy) | 60 | 100 | 26 | 50 | 0.44 / 5 | &lt;code&gt;description-five-axis&lt;/code&gt; |&lt;br&gt;
  | &lt;code&gt;server-puppeteer&lt;/code&gt; (deprecated) | 56 | 100 | 50 | 20 | &lt;strong&gt;0.17 / 5&lt;/strong&gt; | &lt;code&gt;description-five-axis&lt;/code&gt; |&lt;/p&gt;

&lt;p&gt;Every protocol score is 100. The wire format is right on every server. The 40-point gap is entirely how the schemas read.&lt;/p&gt;

&lt;p&gt;## What "0.17 / 5" looks like in practice&lt;/p&gt;

&lt;p&gt;Take Puppeteer's &lt;code&gt;puppeteer_navigate&lt;/code&gt;. The full schema description is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Navigate to a URL.&lt;br&gt;
 Score that against the 5 axes:&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Purpose&lt;/strong&gt; — "navigate to a URL" ✓ (1 axis)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutation signal&lt;/strong&gt; — does it read or write? Silent. ✗&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Side-effects&lt;/strong&gt; — network call, can hit any URL, executes JS, arbitrary cookie state. High-blast. Silent. ✗&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invariants&lt;/strong&gt; — does it close existing tabs? Open a new one? Same tab? Silent. ✗&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Examples&lt;/strong&gt; — none. ✗&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;1 / 5. The other six Puppeteer tools score the same way. Average 0.17.&lt;/p&gt;

&lt;p&gt;A planner LLM that has to decide whether to call &lt;code&gt;puppeteer_navigate&lt;/code&gt; from a tool list of 7 has nothing to pattern-match on. It cannot tell the difference between &lt;code&gt;puppeteer_navigate&lt;/code&gt; (mutates browser state, can hit any URL) and &lt;code&gt;puppeteer_screenshot&lt;/code&gt; (read-only, current page only) from the schema alone — they read identically.&lt;/p&gt;

&lt;p&gt;## Why this matters more than it looks&lt;/p&gt;

&lt;p&gt;The reference servers are calibration anchors. When a server author opens the docs to figure out "what does a good MCP server look like", they read these. When an LLM coding agent autocompletes a new MCP server skeleton, it pattern-matches on these. When the spec doc shows "here's how to write a tool", it links to these.&lt;/p&gt;

&lt;p&gt;If the bar Anthropic ships at is 56–60/100, &lt;strong&gt;that's the bar most third-party servers will start from too&lt;/strong&gt; — and probably stay at, because there's no public benchmark telling them they're under it.&lt;/p&gt;

&lt;p&gt;That's the v1.1.0 thesis: surface the bar so authors can decide where they want to land. &lt;code&gt;mcp-probe score&lt;/code&gt; is one command.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```bash npx -y &lt;a class="mentioned-user" href="https://dev.to/incultnitollc"&gt;@incultnitollc&lt;/a&gt;/mcp-probe score "" --full&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


  The 5-axis breakdown tells you exactly which axis is empty on which tool. Per-tool axis avg below 3.0/5 fires the ≤60 publishability cap. Fix two axes per tool (mutation signal + one concrete example is usually fastest) and the cap lifts.

  ## Methodology

  - v1.1.0 spec: &amp;lt;https://github.com/Incultnitollc/mcp-probe/blob/main/docs/specs/publishability-score-v1.1.0.md&amp;gt;
  - Calibration drift notes: &amp;lt;https://github.com/Incultnitollc/mcp-probe/blob/main/docs/specs/publishability-score-v1.1.0-amendments.md&amp;gt;
  - 6-server summary (canonical): &amp;lt;https://github.com/Incultnitollc/mcp-probe/blob/main/docs/publishability-scorecards/SUMMARY.md&amp;gt;
  - Individual server scorecards: under `docs/publishability-scorecards/` in the same repo

  ## Caveat — install-time security is a different lane

  `mcp-probe` is pre-publish quality (server authors, before they ship). For install-time security (server installers, before they connect a third-party server), see[`@stephenywilson/mcp-doctor`](https://www.npmjs.com/package/@stephenywilson/mcp-doctor). Different audience, different lane, complementary tool.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>mcp</category>
      <category>anthropic</category>
      <category>apidesign</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Tool descriptions are load-bearing too: the anti-purpose pattern in MCP</title>
      <dc:creator>pengspirit</dc:creator>
      <pubDate>Thu, 07 May 2026 14:33:09 +0000</pubDate>
      <link>https://dev.to/incultnito_llc/tool-descriptions-are-load-bearing-too-the-anti-purpose-pattern-in-mcp-15m2</link>
      <guid>https://dev.to/incultnito_llc/tool-descriptions-are-load-bearing-too-the-anti-purpose-pattern-in-mcp-15m2</guid>
      <description>&lt;p&gt;A few days ago I posted &lt;a href="https://dev.to/incultnitollc/schema-descriptions-are-load-bearing-why-missing-parameter-descriptions-break-mcp-clients-4l42"&gt;Schema descriptions are load-bearing: why missing parameter descriptions break MCP clients&lt;/a&gt;. The argument: every parameter without a description is a load-bearing element silently absent from the schema, and agents fail in ways that look like model problems but are actually contract problems.&lt;/p&gt;

&lt;p&gt;The post got a comment from &lt;a class="mentioned-user" href="https://dev.to/mickyarun"&gt;@mickyarun&lt;/a&gt; that's worth its own essay:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The "load-bearing" framing is the right shape — the same observation applies one level up at the tool level. Most MCP catalogues we've audited had perfectly described parameters but no description of when not to call this tool, which is the bit that actually decides whether an agent reaches for the right surface. The half-hour we spent adding "anti-purpose" descriptions to about a dozen of our internal tools cut the wrong-tool-selected rate roughly in half. Arguably the parameter case in this post is just the most visible instance of a broader rule: every field of every schema an agent reads is doing structural work whether you specified it or not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He's right, and the pattern deserves a name. Call it the &lt;strong&gt;anti-purpose pattern&lt;/strong&gt;: every tool description should specify not just what the tool is for, but what it is &lt;em&gt;not&lt;/em&gt; for.&lt;/p&gt;

&lt;h2&gt;
  
  
  HOW vs WHETHER
&lt;/h2&gt;

&lt;p&gt;Parameter descriptions answer &lt;strong&gt;HOW&lt;/strong&gt; to call a tool — what types, what shape, what valid values.&lt;/p&gt;

&lt;p&gt;Tool descriptions answer &lt;strong&gt;WHETHER&lt;/strong&gt; to call a tool — does this surface match the user's intent at all.&lt;/p&gt;

&lt;p&gt;Both are schema. Both are load-bearing. The first is usually under-specified. The second is almost always under-specified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Searches the web" fails
&lt;/h2&gt;

&lt;p&gt;Most MCP tool descriptions read like marketing copy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;"Searches the web for information"&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;"Retrieves data from the database"&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;"Sends an email"&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is fine in isolation. It collapses the moment an agent has three search tools, two database tools, and four messaging tools loaded at once — which is the actual production scenario.&lt;/p&gt;

&lt;p&gt;The agent has to disambiguate. The schema gave it nothing to disambiguate with. So it picks the first plausible match, or the one with the cleanest parameter list, or the one whose name lexically matches the user's phrasing. None of these correlate with correctness.&lt;/p&gt;

&lt;h2&gt;
  
  
  The anti-purpose pattern
&lt;/h2&gt;

&lt;p&gt;The fix is mechanical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before: "Searches the web for information"

After:  "Searches the public web for current events,
         news, and recently published content.
         Do not use for: code lookup (use code_search),
         internal documentation (use docs_search),
         or queries answerable from training data."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Specific scope&lt;/strong&gt; — "public web" not "the web", "current events" not "information"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disambiguation pointers&lt;/strong&gt; — names the sibling tools the agent might confuse this with&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit exclusions&lt;/strong&gt; — the "do not use for" clause&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;@mickyarunreports roughly 50% fewer wrong-tool-selection errors after adding clauses like this to about a dozen internal tools. That's a half-hour edit producing a measurable behavior shift, with no model change and no prompt-engineering tax on the consumer side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why tool authors skip this
&lt;/h2&gt;

&lt;p&gt;Two reasons, both fixable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The author knows what the tool is for, so the description is implicit.&lt;/strong&gt; Authors write descriptions that document the tool's positive purpose because that's what they were thinking about while writing it. The negative purpose — what they consciously decided this tool would &lt;em&gt;not&lt;/em&gt; do — never makes it onto the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP examples don't model it.&lt;/strong&gt; Look at any MCP server template or quickstart and tool descriptions are one-line declaratives. There's no canonical example that says "here's what a production tool description looks like with anti-purpose."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first is fixed by a checklist. The second is fixed by people writing posts like this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concrete checklist
&lt;/h2&gt;

&lt;p&gt;When writing or auditing a tool description, the description should answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope:&lt;/strong&gt; What specifically does this operate on? ("public web", "this user's calendar", "Postgres tables in the analytics schema")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger:&lt;/strong&gt; What user intent should select this tool?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anti-trigger:&lt;/strong&gt; What user intent looks similar but should select a different tool?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sibling pointer:&lt;/strong&gt; Which neighboring tools are the most likely confusion sources, and what should send the agent there instead?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have more than one tool in your MCP server, all four are load-bearing. Skipping any of them outsources the disambiguation to whatever the model happens to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coming to mcp-probe
&lt;/h2&gt;

&lt;p&gt;This is the next axis I'm adding to &lt;a href="https://www.npmjs.com/package/@incultnitostudiosllc/mcp-probe" rel="noopener noreferrer"&gt;mcp-probe&lt;/a&gt;. Parameter-description coverage is already scored. Tool-description quality — including a heuristic for anti-purpose clauses — belongs in the same scorecard.&lt;/p&gt;

&lt;p&gt;Thanks to &lt;a class="mentioned-user" href="https://dev.to/mickyarun"&gt;@mickyarun&lt;/a&gt; for the comment that pulled the framing one level up. Schema descriptions are load-bearing. So is every other field of the contract an agent is asked to read.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>tooling</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
