<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[mrbeandev updates]]></title><description><![CDATA[mrbeandev updates]]></description><link>https://mrbeandev.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 08:13:32 GMT</lastBuildDate><atom:link href="https://mrbeandev.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I turned my Trilium docs into an AI-assisted site with one JS file]]></title><description><![CDATA[A 740-line shareJs widget. Tool-using agent. No build step. No dependencies.]]></description><link>https://mrbeandev.hashnode.dev/i-turned-my-trilium-docs-into-an-ai-assisted-site-with-one-js-file</link><guid isPermaLink="true">https://mrbeandev.hashnode.dev/i-turned-my-trilium-docs-into-an-ai-assisted-site-with-one-js-file</guid><category><![CDATA[AI]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[web dev]]></category><category><![CDATA[trilium]]></category><category><![CDATA[self-hosted]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[Tulasinath]]></dc:creator><pubDate>Tue, 05 May 2026 06:41:07 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69f98e0b46610fd60673cc1f/9b799319-815d-432d-a4d0-26f04928d580.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Originally published on <a href="https://dev.to/tulasinath0118/i-turned-my-trilium-docs-into-an-ai-assisted-site-with-one-js-file-2h2l">dev.to</a>. Cross-posted here for the Hashnode community.</p>
</blockquote>
<p>Last week I had a problem.</p>
<p>I'd built an internal documentation site in <a href="https://github.com/TriliumNext/Trilium">Trilium Notes</a> — about 30 notes covering product, marketing, and operations stuff. It was for non-technical teammates who needed to answer questions from developers about the platform.</p>
<p>The docs were <em>good</em>. Structured, searchable, complete. But reading 30 notes to find one answer is not great UX, especially for someone who's been pulled into a conversation and just needs the gist.</p>
<p>I wanted a chat bubble. The kind every modern docs site has. <em>"Ask the docs anything."</em></p>
<p>So I built one. Single JS file, no build step, no dependencies. It drops into Trilium via a single relation and turns any share page into an AI-assisted docs site.</p>
<p>Here's the result, and how to add it to yours in 5 minutes.</p>
<p><img src="https://raw.githubusercontent.com/mrbeandev/trilium-ai-agent/main/screenshots/widget.png" alt="Widget screenshot" /></p>
<hr />
<h2>What it does</h2>
<ul>
<li>Floating "Ask the docs" chat bubble on every shared note page</li>
<li>The AI uses <strong>tool calls</strong> <code>search_notes(query)</code> and <code>read_note(noteId)</code> to navigate your tree on demand</li>
<li>Works with any <strong>OpenAI-compatible API</strong>: Gemini, OpenAI, OpenRouter, DeepSeek, Groq, local Ollama, vLLM…</li>
<li>Each visitor enters their own API key (stored in their browser) no shared cost, no server-side secret</li>
<li>Settings panel with <strong>live "Test connection"</strong> probe, fully editable system prompt</li>
<li>Mounted inside <strong>Shadow DOM</strong> your docs CSS can't bleed in, ours can't bleed out</li>
</ul>
<p>The whole thing is one <code>widget.js</code> file. ~740 lines. Plain ES2020+. No bundler.</p>
<hr />
<h2>How to install (5 steps)</h2>
<p>You need <strong>TriliumNext ≥ 0.91</strong> the version that supports the <code>~shareJs</code> and <code>~shareCss</code> relations.</p>
<h3>1. Find your shared root note's ID</h3>
<p>Right-click your shared root note in Trilium → <em>Copy note ID</em>, or grab it from the URL of your share page (e.g. <code>https://your-trilium.com/share/&lt;this-id&gt;</code>).</p>
<h3>2. Create a code/JavaScript note</h3>
<p>In Trilium: <strong>New note</strong> → type <em>Code</em> → MIME <code>application/javascript</code>. Paste the entire contents of <a href="https://github.com/mrbeandev/trilium-ai-agent/blob/main/widget.js"><code>widget.js</code></a>.</p>
<h3>3. Set your root note ID</h3>
<p>Near the top of the file:</p>
<pre><code class="language-js">const ROOT_NOTE_ID = "REPLACE_ME_WITH_YOUR_ROOT_NOTE_ID";
</code></pre>
<p>Change to your actual root note ID from step 1.</p>
<h3>4. Hide the JS note from the public sidebar</h3>
<p>On the JS note, add label: <code>#shareHiddenFromTree</code></p>
<h3>5. Wire it up via <code>~shareJs</code></h3>
<p>On your <strong>shared root note</strong>, add a relation:</p>
<ul>
<li><strong>Name:</strong> <code>shareJs</code></li>
<li><strong>Target:</strong> the JS note from step 2</li>
<li><strong>Inheritable:</strong> ✅ <strong>yes</strong> (this is critical, without it the script only loads on the root note, not on its descendants)</li>
</ul>
<p>Hard-refresh the share page. A 💬 bubble appears in the bottom-right. First click pops the chat panel; first message prompts for an API key.</p>
<p>That's it.</p>
<hr />
<h2>How it works under the hood</h2>
<p>Three things make this tick: the <strong>tool-use loop</strong>, <strong>TOC injection</strong>, and <strong>Shadow DOM</strong>.</p>
<h3>The tool-use loop</h3>
<p>Most "AI chat for docs" implementations either dump the full content into the prompt or run a vector-embedding pipeline. I went with neither, because Trilium gives me a clean tree structure I can let the model navigate itself.</p>
<p>The widget exposes two tools to the LLM:</p>
<pre><code class="language-js">const TOOLS = [
  {
    type: "function",
    function: {
      name: "search_notes",
      description: "Search for notes matching a query. Returns up to 8 best matches.",
      parameters: {
        type: "object",
        properties: { query: { type: "string" } },
        required: ["query"],
      },
    },
  },
  {
    type: "function",
    function: {
      name: "read_note",
      description: "Read the full text of a specific note by its noteId.",
      parameters: {
        type: "object",
        properties: { noteId: { type: "string" } },
        required: ["noteId"],
      },
    },
  },
];
</code></pre>
<p>The chat loop is straightforward keep calling the LLM until it stops asking for tools:</p>
<pre><code class="language-js">while (true) {
  const choice = await callLLM(history, base, key, model, systemPrompt, toc);
  history.push(choice.message);

  if (choice.finish_reason !== "tool_calls") break;

  for (const tc of choice.message.tool_calls) {
    const args = JSON.parse(tc.function.arguments);
    const result = (tc.function.name === "search_notes")
      ? searchNotes(args.query, idx)
      : readNote(args.noteId, idx);

    history.push({
      role: "tool",
      tool_call_id: tc.id,
      content: JSON.stringify(result),
    });
  }
}
</code></pre>
<p><code>searchNotes</code> and <code>readNote</code> are pure JS they run against an in-memory cache of your notes (built once, on the first message). No extra HTTP calls per tool invocation.</p>
<h3>TOC injection</h3>
<p>The model needs to know what notes <em>exist</em> to know what to search for. Instead of describing the docs in prose ("This documentation covers product, marketing…"), I just dump a structural Table of Contents into the system prompt:</p>
<pre><code class="language-js">function buildToc(idx) {
  const lines = idx.map((n) =&gt;
    `- \({n.title}  [\){n.noteId}]  (${n.breadcrumb})`
  );
  return "Table of contents:\n" + lines.join("\n");
}
</code></pre>
<p>So the LLM gets something like:</p>
<pre><code class="language-text">Table of contents:
- What is Visibility?  [S4qFGrSy9D5w]  (Knowledge Base › Product › What is Visibility?)
- Who Uses It  [Ovum6sM7fALz]  (Knowledge Base › Product › Who Uses It)
- The AI Agents Explained  [OePtXwrRuC7w]  (Knowledge Base › Product › The AI Agents Explained)
- ...
</code></pre>
<p>Now when a user asks <em>"What does Visibility actually do?"</em>, the model sees <code>What is Visibility?</code> in the TOC and calls <code>read_note("S4qFGrSy9D5w")</code> directly no embedding lookup, no chunking. Just a model that can read.</p>
<p>This works because TOC is small (one line per note × ~30 notes ≈ 1.5 KB) and the model only fetches the bodies it actually needs. Token usage stays cheap even for hundreds of notes.</p>
<h3>Shadow DOM isolation</h3>
<p>Trilium's share page has its own CSS. CKEditor styles. Theme variables. Pre-existing rules for <code>&lt;button&gt;</code>, <code>&lt;input&gt;</code>, <code>&lt;label&gt;</code>, etc. If I render the widget directly into the page DOM, those styles bleed in and the form layout breaks in weird ways (I learned this the hard way).</p>
<p>Solution: Shadow DOM. The widget mounts into a closed-off shadow root:</p>
<pre><code class="language-js">const host = document.createElement("div");
host.id = "trilium-ai-host";
host.style.cssText = "all:initial;position:static";
document.body.appendChild(host);

const shadow = host.attachShadow({ mode: "open" });

const styleEl = document.createElement("style");
styleEl.textContent = css;
shadow.appendChild(styleEl);

// ...everything else gets appended to `shadow`, not `document.body`
</code></pre>
<p>Trilium's CSS can't reach inside the shadow root. My CSS can't reach outside. The two coexist without fighting.</p>
<p>CSS custom properties <em>do</em> pierce shadow boundaries (by inheritance), so my rules like <code>background: var(--background-highlight, #2a2a2e)</code> automatically pick up Trilium's theme light or dark, whatever the visitor chose.</p>
<hr />
<h2>What I'd do differently next time</h2>
<p>A few things I learned that might save you time on similar projects:</p>
<p><strong>1. Don't use <code>innerHTML</code> if you can avoid it.</strong> Trilium's note-content validator was rejecting my JS because it pattern-matched HTML-shaped strings inside template literals. Switching to <code>createElementNS</code> for SVG icons and <code>document.createElement</code> for everything else fixed it instantly.</p>
<p><strong>2. The OpenAI tool-call format is the new lingua franca.</strong> I started with Anthropic's native API. Switched to OpenAI-compatible because it's what literally every provider speaks now: Gemini ships an OpenAI-compat shim, DeepSeek does, OpenRouter is built on it, every local LLM runtime exposes it. One format, every provider.</p>
<p><strong>3. Per-visitor API keys are surprisingly OK for internal tools.</strong> I worried about asking each user to enter a key. In practice, the marketing team uses Gemini's free tier and never thinks about it again. No shared budget to manage, no key rotation, no server-side secrets to leak.</p>
<p><strong>4. DeepSeek's reasoner needs <code>reasoning_content</code> echoed back.</strong> This one bit me, DeepSeek's reasoner model returns <code>message.reasoning_content</code> separately from <code>message.content</code>, and you have to include it in subsequent requests or you get a 400. Other providers don't care, but if you support DeepSeek, preserve it on the assistant message:</p>
<pre><code class="language-js">const entry = { role: "assistant", content: msg.content || "" };
if (msg.tool_calls?.length) entry.tool_calls = msg.tool_calls;
if (msg.reasoning_content) entry.reasoning_content = msg.reasoning_content;
history.push(entry);
</code></pre>
<hr />
<h2>Try it</h2>
<p>Repo: <strong><a href="https://github.com/mrbeandev/trilium-ai-agent">github.com/mrbeandev/trilium-ai-agent</a></strong></p>
<p>MIT licensed. Single file. No build step. PRs welcome.</p>
<p>If you've got a Trilium share, you can have AI on your docs in the time it takes to copy a JS file and add a relation. If you don't have a Trilium share but want one, <a href="https://github.com/TriliumNext/Trilium">TriliumNext</a> is the most under-rated self-hosted note app in 2026 and it's worth the 10 minutes to set up.</p>
<p>Happy to answer questions in the comments.</p>
]]></content:encoded></item></channel></rss>