---
title: "Build natural language filters for real-time analytics dashboards"
excerpt: "Natural language dashboard filters let users query data by asking questions. No SQL knowledge required. AI handles the translation."
authors: "Cameron Archer"
categories: "AI x Data"
createdOn: "2025-04-09 00:00:00"
publishedOn: "2025-04-10 00:00:00"
updatedOn: "2025-04-24 00:00:00"
status: "published"
---

<p>If you have a real-time dashboard in your application or plan on building one, you can improve it with LLMs. There are a ton of ways to add AI features to your real-time dashboards, but here, I'm going to focus on <strong>filtering</strong>.</p><p>You know what a dashboard filter is: the little pills, checkboxes, and dropdowns you can click to filter the results. A proper real-time dashboard will update to show filtered results almost immediately.</p><p>But let's say you have <em>a lot</em> of filter dimensions. Sidebars and filter drawers get clunky in this case. Better to just have a single text input. Pass the input to an LLM, and have it generate the filters, like this:</p><figure class="kg-card kg-video-card kg-width-regular" data-kg-thumbnail="https://tinybird-blog.ghost.io/content/media/2025/04/blogpostvideos_thumb.jpg" data-kg-custom-thumbnail="">
            <div class="kg-video-container">
                <video src="https://tinybird-blog.ghost.io/content/media/2025/04/blogpostvideos.mp4" poster="https://img.spacergif.org/v1/1284x542/0a/spacer.png" width="1284" height="542" playsinline="" preload="metadata" style="background: transparent url('https://tinybird-blog.ghost.io/content/media/2025/04/blogpostvideos_thumb.jpg') 50% 50% / cover no-repeat;"></video>
                <div class="kg-video-overlay">
                    <button class="kg-video-large-play-icon" aria-label="Play video">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                            <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"></path>
                        </svg>
                    </button>
                </div>
                <div class="kg-video-player-container">
                    <div class="kg-video-player">
                        <button class="kg-video-play-icon" aria-label="Play video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"></path>
                            </svg>
                        </button>
                        <button class="kg-video-pause-icon kg-video-hide" aria-label="Pause video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <rect x="3" y="1" width="7" height="22" rx="1.5" ry="1.5"></rect>
                                <rect x="14" y="1" width="7" height="22" rx="1.5" ry="1.5"></rect>
                            </svg>
                        </button>
                        <span class="kg-video-current-time">0:00</span>
                        <div class="kg-video-time">
                            /<span class="kg-video-duration">0:09</span>
                        </div>
                        <input type="range" class="kg-video-seek-slider" max="100" value="0">
                        <button class="kg-video-playback-rate" aria-label="Adjust playback speed">1×</button>
                        <button class="kg-video-unmute-icon" aria-label="Unmute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M15.189 2.021a9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h1.794a.249.249 0 0 1 .221.133 9.73 9.73 0 0 0 7.924 4.85h.06a1 1 0 0 0 1-1V3.02a1 1 0 0 0-1.06-.998Z"></path>
                            </svg>
                        </button>
                        <button class="kg-video-mute-icon kg-video-hide" aria-label="Mute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M16.177 4.3a.248.248 0 0 0 .073-.176v-1.1a1 1 0 0 0-1.061-1 9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h.114a.251.251 0 0 0 .177-.073ZM23.707 1.706A1 1 0 0 0 22.293.292l-22 22a1 1 0 0 0 0 1.414l.009.009a1 1 0 0 0 1.405-.009l6.63-6.631A.251.251 0 0 1 8.515 17a.245.245 0 0 1 .177.075 10.081 10.081 0 0 0 6.5 2.92 1 1 0 0 0 1.061-1V9.266a.247.247 0 0 1 .073-.176Z"></path>
                            </svg>
                        </button>
                        <input type="range" class="kg-video-volume-slider" max="100" value="100">
                    </div>
                </div>
            </div>
            
        </figure><p>Here's how you build that, step-by-step:</p><h2 id="context-data-and-prerequisites">Context, data, and prerequisites</h2><p>Before I dive into the implementation, let's set the context. We are going to build a dashboard filter component that:</p><ul><li>Uses an LLM to parse a free text user input and apply filters to a real-time dashboard</li><li>Refreshes the dashboard very quickly</li><li>Filters performantly even when the underlying dataset becomes very large</li><li>Can handle large sets of dimensions with high cardinality</li></ul><p>For this tutorial, I'm riffing on this <a href="https://www.tinybird.co/templates/ai-analytics-template"><u>open source LLM Performance Tracker template</u></a> by Tinybird, which includes a natural language filter feature (see the video above). </p><p>The underlying data for this dashboard has the following schema:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAKvCQAAAAAAAABBKUqGk9nLKu-SvJFnh6-yrX-SOudIvOBu7HKa214rD8Ef6qrFvAf1TswLfXhipj24GXuQHTgA1Xue7dV3-BGYmvhsgaw1Isc2BFMGm5DiOvBWJkZqoDrL4O-iLM7mQrbrdIEOUS8Ad-ktkY9iyaj0o02dD23aGkNTJIAz46LgJE5v2V__kZK4zADNowpYGfmJUIQvPhTEUzB3VA5RxmZtdrI8Fh0mnYv5oeI6yccZqKM5YdVBf9ASErqaXHWJ8vSR9HqY34brQWSVOEZ8ngIKIjy4M9-pOHmu4fM_ElU5Z_uatU4-pBKflotAA_VSB3bbVBs6VTq30eM2fc2DAGMK15YOCvmmpyeJ6mf5sZj_IobtgS-Rvk6AfiEjSIPNUVeC4qdNeYgpEjfF8lBsQ0ehLguaPP4JtyREaOOKm6qgvGMIki8_HOgH2avx0Mi-DJghHQNMnJA4JrtDsmki2II_m0BAuyjtK473M6BbUcOMA-Y4x0w_BR1m2D6H1zQEZKa9kfyJIpCGGK7iXt76i1WshIy3MxAeePLh4xSNWki7os965UN_YgPmHY1xt2yK6O4SdcDcvBFesNbcwy_e9A6WU8LlrLV0h2VoFkLZoRbVs7bBnH_F3oKZLgpsSyj7ci-1A6m175xz7Toyx2YEw2e_xDLF9uRhD0Ed_9u_7BAZBolKXjLqi5_FW8KNvuemKtEIDsbBmSLaZ8t5_Fz8tJQfISL1ZTkjXUhdBj_z6_ym8t1ezUoM-itfHtGXsRzOnvTil5goCUdRXftdoMd6nPE0fVJZmYfF9OFhyo0-pYfRTgkPPXQQD0-9aSaYvXvmx52o0wJqvUtKMxxdUajSem8PojaYx0FRmtlDkqxCxJ4vfCovlrb_jITJpQZtaYLu09jvNo9nDV5QxoL3v1vXLNX8Bsj-KW2AfUp9XKAt0GB46xcqalh1hO1GjU21hD4-IpgIJQDxb-8M02OENyVJXzUT_WbK-5fs8IZ_Up8FTYE4bnD_h7hmzA/embed"></iframe>
<!--kg-card-end: html-->
<p>You can see it's storing a bunch of performance and metadata for LLM call events. </p><p>The <a href="https://llm-tracker.tinybird.live"><u>live demo</u></a> allows you to select values for the following filter dimensions:</p><ul><li>model</li><li>provider</li><li>organization</li><li>project</li><li>environment</li></ul><p>When you click a specific model, for example, the dashboard will update to only show metrics for that model.</p><h3 id="prerequisites">Prerequisites</h3><p>I'm going to assume that you already have a dashboard you want to filter, so you can apply these steps generally to your use case. If you want to create a quick data project to follow along, use these commands to bootstrap something quick with Tinybird:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAJaAgAAAAAAAABBKUqGk9nLKveOeUeNVRoymZRuYFKcY046GsC_BPXtM_pjXV5_xBXsYyG9N_v2pWcbY-XA3OLz8iC-YFDyq-8mO6Te5yLzXm_iLtpk0lur0DcUpfja43BfuoHcACZNSxP61lqZQdBSvbK70lF1x88k-6pl9cViwNISi9p0Q9zMts87vFNgV6-kJRKbdEKNl0GbPtCqG8s8LNobWuuH7HKr37ITq-iLutz8BWW-Z4nNFb7oGdt0M8eYYwEkMiwL3B4E9rfo1I6XMfjlxAhXnJZ0JigCN9B_12tsDI0sEBPfiuDQ6NTKV4SzlZl3IwVUPnOhVJTak5wZqEKoE0vNU_lLII1VJcPcIxBJ2HqbGXrCI4WuP1y4xDBz_gwxI0p3rbLuhvQmR85Og3NjhyfcvAvgF4uDptuwcldR7QAVV8oG8z6XCljLHRO4VvUmrpYU6YhEaYqpDJaHsKqJtisTZYXp2T1-VYhyTM1Yhha3EkjPor-UsG2jg0Om9QReQXnWSryf_75xmOs/embed"></iframe>
<!--kg-card-end: html-->
<p>That will deploy a basic Tinybird datasource and API endpoint on your local machine with 100,000 rows of data for testing.</p><p>Now, let's see how to replace "click-to-filter" with "prompt-to-filter"...</p><h2 id="step-1-review-your-api">Step 1. Review your API</h2><p>I'm assuming that you have an API route for your real-time dashboard that can accept various parameters to request filtered data to visualize in the dashboard. Something like this:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAALRAAAAAAAAAABBKUqGk9nLKvXryMbH2LTjsdlmSeHJVnkJC1mZ9l4USkYpmzR2068wfWR7Yg-hqZkJsqKsYf99UHdEdygG35Am1_7Fksh05UEyRAr3kZCLiNf4W_bCuqK4P6zis9dWp33XFgtXW9x22tYugxFdXV2_zVLSlN5vO_NBMB-g879ehYDbQJJvk-HCIRfwPdl8lkRxTlOSovtd5JoTCy3TWDTWeEj4EAbyd0j8ucgWhlupkb4PsrdS___ty6AA/embed"></iframe>
<!--kg-card-end: html-->
<p>In Tinybird, for example, any SQL pipe you build is automatically deployed as a REST endpoint with optional query parameters.</p><p>My Tinybird API definition looks like this:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAJeCAAAAAAAAABBKUqGk9nLKwKb9MbH2LTjsdlmSeHGi6a8CffmoWg67pGvcnyq864zXJpDpHUPTTI4D653d3YXan-sM5LuZSaDy2uZlEuKx9orH95Wc39c3G4oW8R2H97ds-T12y_h5b3JNiQ9nI5qHODqjqHXWKipj_sl_MpfLVP3N2jwxPSuf3CE_oL2lc3lloDgsgq9FVjRutrBSkRzri9E_QLWZ3BIeYf1Eit2BIpdzifS65nvs4ZDJ-0Telhzgb3OiYPXlsn3eF_Sm6Zsy55ygqsU_p0mD1cRrJheBu7awCxsSHjA_Agbe30dQeqPENWiIpMqD_XMcoHHWMHvApHKmPwE-74s2pe_Jw_ELfivz5nsUjgVRK-MjDVCMUwyF5Vt6bOkheLBnQQQUphzAhbkVWjsyvjISYq-_9vPQVp8XmEN7GSQwN7De5zMpvrOp_6IjNxDyf7q1vObsddlm6N6A4XTK7xV70X73udNOwtvBvyOng1DcCLJWOHdHFYqyo_PMwkyRAjIyp8gdGPkCVPQdRyuaEQ0wLVkfOj7tCVuknZoAaW1KdLwFvcXpOojY7VC0hgt5HbCJt8ewAYlte53BK7qP5-Us4g7o70XIIZn-aTLBJ-XaA2ffO3bwOK-7V_KLdtsX-3FMlaKktSbceocUEQEj2eHtgoAOYCxuFDzija1nKyCFnJbfX_38lARN5DxHdta9pVtCokqzr8kZkXbGjWL9WN-x3u_lPUiXDqoc7dxf4sRHcrQ-_QzCx-Mdm71sqeBDd2E9eYbEq4RNcaWgAVopKinoyB2fsePBjEtRWyM5M_9ko94GkPhWNEeKuznvtAX7IMnzAL7x9Q2pKWIMnYmp1u8JVCWv4G2dnyaLqQOHl9kv1gFuJWMwstloQIIXM_NCL-O51O6r2rPjhh8GC0HuvnyaMmKNa0Q7-iWywcsO11ZUpP5gTgWNXoea9lvPCUzqX7RrryaoR2S0zj_7QMkuw/embed"></iframe>
<!--kg-card-end: html-->
<p>A quick summary of this API:</p><ul><li>It uses Tinybird's pipe syntax, defining a single SQL node to select from the <code>llm_events</code> table.</li><li>It returns time series aggregations, grouped by <code>date</code> and <code>category</code>, of various LLM call metrics such as errors, total tokens, completion tokens, duration, and cost.</li><li>It accepts a <code>column</code> parameter that defines the grouping category (e.g., model, provider, etc.)</li><li>It accepts many filter parameters (e.g. <code>organization</code>, <code>project</code>, <code>model</code>) which are conditionally applied in the <code>WHERE</code> clause if they are passed.</li><li>These parameters are defined using <a href="https://www.tinybird.co/docs/cli/template-functions"><u>Tinybird's templating language</u></a>.</li></ul><p>So I can pass a value for any of these filter parameters, and Tinybird will query the database for data that matches those filters and return the response as a JSON payload that I can use to hydrate my chart.</p><p>In the past, I'd create a UI component in my dashboard to allow a user to select those filters. Here, we're using AI.</p><h2 id="step-2-create-an-llm-filter-api-route">Step 2. Create an LLM filter API route</h2><p>To start building your natural language filter, you need a POST route handler to accept the user prompt and return structured filter parameters.</p><p>The API route should implement the following logic:</p><ul><li>Accept a JSON payload with <code>prompt</code> and (optionally) <code>apiKey</code> fields (if you want the user to supply their own AI API key)</li><li>Fetches the available dimensions for filtering</li><li>Define a system prompt to guide the LLM in creating structure parameters for the response</li><li>Queries an LLM client with the API key, system prompt, and user prompt</li><li>Returns the LLM response (which should be a structured filter object as JSON)</li><li>Error handling, of course</li></ul><p>If you want to see a full implementation of such an API route, <a href="https://github.com/tinybirdco/llm-performance-tracker/blob/main/dashboard/ai-analytics/src/app/api/search/route.ts"><u>just look at this</u></a>. If you want step-by-step guidance, follow along.</p><h2 id="step-3-define-the-system-prompt">Step 3. Define the system prompt</h2><p>Perhaps the most important part of this is creating a good system prompt for the LLM. The goal is to have an LLM client that will accept user input and consistently output structured query parameters to pass to your dashboard API.</p><p>Here's a simple but effective system prompt example:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAKAAQAAAAAAAABBKUqGk9nLKv9v6MbH2LTjseFfl3ymtNX6N6q6nHusAho00WsQQDX7e6Ok-kVNQHkSmlkvnjAT_UtbYR77VRh5wF8_ArQEugQ-F7h5KDG28DW6IH_E-Pdk5CiDNMhqlqfIsBKGaDqYx3G0voGIv74qXtn4fFD914SJsSPnSQjJoid2kw6cPvr8UykNYiG3QtXnPJDLMNFTJmWZhmUHeKd2icJM7ryb2ZENLBN2FaykfhtvUSIeEVhGQ0bkvAt-ob3bsr9EoeVduDqVG-abC_j0reIIsSUuUSVT1NL9ZstAtLumUgnaSV0jmF6i--oocrzCFGdXIXw1R79N0NY9faLtQ4BLZ8WQIJNoiAP9W6_q7XG8mlOxSWNqg8ThCEMA7vDb8xPw_9A4YwA/embed"></iframe>
<!--kg-card-end: html-->
<p>You could further extend this system prompt by passing available dimensions and example values. To make this work, you can query the underlying data. A Tinybird API works well for this:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAIcAgAAAAAAAABBKUqGk9nLKzhNiUliMXYTiCI1cxEHZMBnyE-Ff9CBPPnEoT-2eKKqK7r5szaZCx3jSWY4UXyDnuhuYggg59o9PKuZZP7-TVb4Sjk8XtePzHPFNp1FAaaezfiZXobOkG_qItJr7emUO7uY5BEFSE-tLA7d1EQQuREkxGmfxId_nq874y-QIi76MhvLAdxZ3xzE8PZoN3AcVUPG1ev3Q19TufJ6b0OaUP7zZY0ZG4m55K_srGLuZbE5GMMzMNrclj4p4aAqhLWkc4lM4YnFxwm03sOrUaJGES2JzRX48ej3iLC6CcJH-5kXOyNMpzXxrGNWwBFJfJvpdsbe2fOYXbYsj8C5TdwNZWV3Gews_gmMyrWMhW5thfxRaniaxxvMeje8ZxO2MXeVbjJdnEnq3BD_GaLFAA/embed"></iframe>
<!--kg-card-end: html-->
<p>This queries the underlying dataset (latest month of data) and returns an array of possible values for each of the five filter dimensions defined in the API.</p><p>This API can be used to show the LLM what is available.</p><p>You could create a little utility to fetch the dimensions and unique values:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAALCAQAAAAAAAABBKUqGk9nLKzhSld8APKMT5GgIkmIQyQR1iJR3HlhgEUyNgREz24LSoNH6a-snc8k20H-i4h8rIVAmm9KmTNLllE89Gn0T6td1JKYNZkPORDzyAFVmHs1FEgje2owRkboJaEifebvFlvDOtlJOcnFMx_jgvePNWDb1tqfB5q2h3rdIw9uUzOe9G4CdDuDSbLvoQWnzBxyWfVVyJ0hQeJShvxOZ0R9Jyi1U8rzupptM17NOGCHFDdUroYsOxEjICAzHJmJ1z9Lh9HOuEHa3U65J0Tk1HuLYqaIYfp1faGMrDsmeb8VLDhyiZ9U4tIHpHFBFUmfvY4DooU1fQOEzqUOOlcrekI1aqHv2adxN90MMavQ7tah-crMIcuZ0yZlZU_VLsGKyygE6KlYJizTO8P_apwWl/embed"></iframe>
<!--kg-card-end: html-->
<p>And then call that to define the system prompt dynamically:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAALYAQAAAAAAAABBKUqGk9nLKv9vBG4Xv5g_fXgoQfW0sgbjVMRyFKH_txrIcgBMZa22ecgoku6ckipFpYafH1CQyUUrkesZ3eB6koLsdUiSQQnqaqfEAsHzbaWweGX7lxpCSS8eZnKJ53qSHotpJtcfYZGkPlgcwQBt0IoYci7Oq0Aa3ey2fGP6P-BwxXL7gzzFzJLNfYOZimrT-jXfdpei9LZaLCQ4iSOxqlGJmqPI61X3FX6nS3njNDfvSA8D3kdcQ4Njx_cwCaIKfzwiB7oDnQSDT3bK03RFFqK27JxfIGntoEcGCXJwgL1lq8mj2uGOOMvvOeQYsckZdwGDsvgpTbLKYH9lWxY04iiCsD3tCI1j0LRBeXAQq-zHmW6EZ9z0AIP0Tt-j7wrrLM8qNpKz9sk6OCops8Cxx0JG6aFeSYXK2KHv24H-6npxWKbbc25N2jTqScxr_-ZjwAA/embed"></iframe>
<!--kg-card-end: html-->
<h2 id="step-4-create-the-llm-client">Step 4. Create the LLM client</h2><p>Once you've defined a good system prompt, it's as simple as creating an LLM client in the API route and passing the system prompt + prompt.</p><p>For example:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAL2AgAAAAAAAABBKUqGk9nLKv3YFWfZGOBay5HnzmRPyatNi9Zu-hPXvhPqkoC5hBIzauOWA64nDMWKPBVTbk9qCi5DqGQ2MZp8mm4JtgSPvpFBT24D6doS7u7Cno7UPcreg4aWUc4iG4-Msz_ahJMh8H2eWkq3NLQDmCRO-bSrkPaNHwNvPeSNSKWwJ198muShZl62aIZCXNfQzotNIVZKplDaerEjsqjl4JbMAHO-mZKQwo-v37KQhDBkAzexn-FtdjqBPbUsE2MfiXhoocZL0P3jUaPeUHwDibUl9qePjG909xCpt7JVmr9Hwa84thoSlfwAOHX75TFXF_FCGVVD3xB1tIl-UhPn6fLN6QNv9PPHjBKExmX6DTzpvP_qDNXuI62xOh7rPfK9neV7WYLg1Kzox0dVRP0tDpm4pyBGhfP2gEndUdYDbMEd2P2eRKChs5cwpjLWAmHWsXrcpBvtQguTlop47p6csUNQGoZQFSBeIGPJhy36wf-lAl8NLUj3tAcsPZTCCXHck9gVb4nDJ4k0ZZ28rCuyOuBo9rzs2fbthBwiRGscXOMaGSYTQiEyv9jUsddxdqAmlDVkZVLJ5p9L_lon2g/embed"></iframe>
<!--kg-card-end: html-->
<h2 id="step-5-capture-and-pass-the-user-prompt">Step 5. Capture and pass the user prompt</h2><p>I'm not going to share how to build a UI input component to capture the user prompt. It's 2025, and any LLM can 1-shot that component for you.</p><p>But the idea here is that your API route should accept the prompt input when the user submits the input.</p><p>For example, here's a basic way to call the LLM filter API route (<code>/search</code>) within a function triggered by an Enter key event handler:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAKdAwAAAAAAAABBKUqGk9nLKwqKExLzIuBeA_CPEglbdMSOEoeo58iRoDYAQ4Bo24AToYlMGksjOfDQ98ulvTooaP1lSx6w5Ca-UJHu06L7a5Lxr9wWgXIOt6u3Y_amPVvy5ADpKEX_K6kcEMzOgteVSh_VMTONu2sDoHHf9ULWIQKasKXPnYrnsjyA2znJmKLCNfAesd7AKergVn3d9-yRNDNOpDYV3G8QIXArrYDkOUFCQR6KNl6nUJW4sG6obmUeHsjdpOyIvXYsKRq5vyyILaC7-aWbXdBW-U_BDm7Lki8w38urAYhs2Kx6DC2mXDK0gV6bWlgJw0SjVX_bQRp2n-11axtLHK_Tuvp6paPFyvkwShT55gwBwh0igr0UAc3jutbQIREIeiQ-G5LmSNmi40mqwchnftNPChnTCrToLX2i4gGo6E19ASzELIzHnGouz-knbyD0NutscXOba90wl6Z9-t3hbQ9Tf_fFdL4_Mvib-DwRiBMoapSTb9b8y3zsrVfxM_u3ewSF0zWX32br-OUUbh7r_AB7P0yn04pPgO9AranvSHZJvZZ2VIOlVzsrlBq56kGNYDe-Ba17YEaKzo3NlJqbUB2MGbN_opfEG6ztlWo0llvdlX6qK0TYGO0kyun1O61J2bYdOm9iZJMHP47FRWirnbf3F7huYIdQsOXPeamlzAG0UkAUKaI6r5uNh7bjD1Lv4OUlgk4Zlb7_x5oev2h-oPc2Ztnw-VVgqw/embed"></iframe>
<!--kg-card-end: html-->
<h2 id="step-6-update-the-filters-based-on-the-api-response">Step 6. Update the filters based on the API response</h2><p>After you've passed your user input to the LLM and gotten a response from the API route, you just need to fetch your dashboard API with the new set of filter parameters.</p><p>For example, taking the response from the above <code>handleSearch</code> function:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAIwAgAAAAAAAABBKUqGk9nLKv3onWRgLJY4eUrLPbX6z7r7leSopP-X8ouGikwO0xpKOzZlPJr2KJR3T9LL4dc2Ty2vAV3CLnRDeDMXzLm_565GKhj5Cao2JB45HHcVScVcn2eFwJJ98-Jdl_Fgh1jNc9n7MRx_RMMMCtubTcvC072P3niNhjM9NMozWeFHEIFxCNW2NkRoaDfvJLjqgzljy6mC_R-5_OmtXOsG3pOyf8zqc5MVPk2rYfvR8HjADL81b1h7qcgmzvW2o2ICgJihKROUPs83t4bKjfnXYiycWR8YNNy1V7I-7HYIq_1SocEZCmNs-riasl6z25zJ439bxkPqUtZh67iNLuVd1zF1VKrBWLbOsqabtLHBb-vNZo8zghy8cKhTIaxW4Un4zpyUFEfYBMsXn4r6ICG0F-7U9Ek8nXbzHofbzAEDqBn5DHxcWNMDzRJ8kENPYVrcsuJs211kQDNUTCOYwszxf9e1c4FvGzMIEh_7DMlE__VInb8/embed"></iframe>
<!--kg-card-end: html-->
<p>In this case, we add the new filter params to the URL of the dashboard and use the <code>useSearchParams</code> hook in the chart components, updating each chart with the applied search params.</p><h2 id="step-7-test-it">Step 7. Test it</h2><p>So far, we have:</p><ol><li>Created an API route that accepts a user input, passes it to an LLM with a system prompt, and returns a structured filter JSON</li><li>Added a user input component that passes the prompt to the API route</li><li>Updated the filter parameters in the URL search params based on the API response</li></ol><p>So, looking back at the data model, let's imagine we used the following text input:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAJwAAAAAAAAAABBKUqGk9nLKvRVwxLzIuBeA-9AdlCQRu94K2vaO1i8dcr-aJOmTQwqDat4_8FMEzouDIO06JPYo4wlORDH33hTF84h2UblCy3wuyycrUqtzkK7Wy8npKmwYyrzeQhgVGJbpIk5d99xLcMf__i8EAA/embed"></iframe>
<!--kg-card-end: html-->
<p>The search API should return something like this:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAKOAAAAAAAAAABBKUqGk9nLKvxQhESTd-YEhO5clMlAKeYU5zCyc8ck8n7JDemhuxT_6QH-_P39Rdp80f6265aCv8sDA4zMOGjljaxACeI_Ujad5w8cPpuYRkAvUnETEgiN2umagCtKNgbZYuYbpK66jdC3WTCm1FWokcWRG08c-bZoVzu9AXFj__9UW0AA/embed"></iframe>
<!--kg-card-end: html-->
<p>Which would update the URL of the dashboard to:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAKeAAAAAAAAAABBKUqGk9nLKv9v6MbH2LTjsdqz6j1GJR8T9djtq6qIYm1NZK9HpMjRoOwqY578fsCXX4xYzGJSYis584VEByP70cthvZIaek5FDT3WBwTxxwAlYWshIh0icxrh9Z-YJBS3-a-tiDMVqQBAASDK0-g2IU1uJiEBo8OpN6Cvi5STTrsPtr3Tp5R9QE3wNbhD-P___NzQAA/embed"></iframe>
<!--kg-card-end: html-->
<p>Which would trigger a new fetch of the Tinybird API for our time series chart:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAKpAAAAAAAAAABBKUqGk9nLKvqur2i5w87exbFwsSG-My0wr21GkZih7_Hzi-hamS7EBXD-9S6GCwl4VATVCzZ8CIj_C7ayEjU0XlQJZeVk2Y4PaNEP7ZM0RA0I0CmLhkMwQoIKspNi4M06o6PyI2h9PjMXgc5RBKiR1Uo472PEznqT3Z_wmdoY0GDXzHCdD_JJbDPrFhiRNrTWjI_iEk99m___cN8AAA/embed"></iframe>
<!--kg-card-end: html-->
<p>Giving us an API response that looks something like this:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAImAgAAAAAAAABBKUqGk9nLKwQuYlLMoOvoQ2D_lV5Io88R1bhRmBbC0K1wrcRBg3xaxHWsc1k-IgKJH5BqpOuQDbdhfAhwDlGNMxrojlSfSvOgarePFj4mChn9qr4N93YVOfERKvuqNS9cOOMM0bSdqDKnHNwfxyc0qjG2tZIf0nrTPCG4hMUJGxY4ewCXP2kmfmGfnARj9Vdl2kd4LnOOf-HVowgPyT1DypJru0DKRSX9MsfrEV1TS6jMa4_34W-S0xS1ovQEBCsgF39XPLim7xRwZj5N7F7Cpmv-ImCwrn_mHpy3hi12_HhQ9NCRHlr4XdLdqt6YgR3MHeuj-ZI6GHHRDN4kFz-FcbX4FWsIt4mzfxMzX-AU5WdwYsAYD1dvhsVQspHNiPzLDz79FTENlZd6vWIc8UDubZJvg181aJlfOFosKLesvjMgrOxAL6N2O3glhsabZ-QFAz4fX_-aD-4A/embed"></iframe>
<!--kg-card-end: html-->
<p>Which we can use to hydrate the chart. Boom.</p><h2 id="performance">Performance</h2><p>A real-time dashboard should filter quickly. With a typical click-to-filter approach, we don't need to worry about the LLM response. In fact, if you look at the statistics from the Tinybird API response above, you can see the filtered query took just 7 ms, querying about 5000 rows.</p><p>Of course, as events grow into the millions or billions, we might expect some performance degradation there, but there are plenty of strategies in Tinybird to maintain sub-second query response times even as data becomes massive. This is the benefit of using Tinybird.</p><p>As far as the LLM response, you can query the underlying Tinybird table to see how long the LLM takes to respond, on average:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAKyAAAAAAAAAABBKUqGk9nLKzhPPhy5AfLMhJl9P-l8QzYp80m6OtJii6ray4q5cysOTIF10THxx5vzZCJDmP-FlYUqzZHFHWMlTbrkaFPdjsPHHdOdPaulTIX4zNKomI8WLyTG-XI6NcKxKkpKfPX71ie8staQP9zWx2L3XHtFQD0kdOgOZvkge4tscocf-TQ6Zqki-2F7JdHd0hPEJ68DzAbWN8_mS3jf__LcdeA/embed"></iframe>
<!--kg-card-end: html-->
<p>By the way, the LLM Performance Tracker template on which I based this tutorial  actually includes a filter selection to analyze your own LLM calls within the dashboard, which we can use to see this in action:</p><figure class="kg-card kg-video-card kg-width-regular" data-kg-thumbnail="https://tinybird-blog.ghost.io/content/media/2025/04/test_thumb.jpg" data-kg-custom-thumbnail="">
            <div class="kg-video-container">
                <video src="https://tinybird-blog.ghost.io/content/media/2025/04/test.mp4" poster="https://img.spacergif.org/v1/1920x968/0a/spacer.png" width="1920" height="968" playsinline="" preload="metadata" style="background: transparent url('https://tinybird-blog.ghost.io/content/media/2025/04/test_thumb.jpg') 50% 50% / cover no-repeat;"></video>
                <div class="kg-video-overlay">
                    <button class="kg-video-large-play-icon" aria-label="Play video">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                            <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"></path>
                        </svg>
                    </button>
                </div>
                <div class="kg-video-player-container">
                    <div class="kg-video-player">
                        <button class="kg-video-play-icon" aria-label="Play video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"></path>
                            </svg>
                        </button>
                        <button class="kg-video-pause-icon kg-video-hide" aria-label="Pause video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <rect x="3" y="1" width="7" height="22" rx="1.5" ry="1.5"></rect>
                                <rect x="14" y="1" width="7" height="22" rx="1.5" ry="1.5"></rect>
                            </svg>
                        </button>
                        <span class="kg-video-current-time">0:00</span>
                        <div class="kg-video-time">
                            /<span class="kg-video-duration">0:20</span>
                        </div>
                        <input type="range" class="kg-video-seek-slider" max="100" value="0">
                        <button class="kg-video-playback-rate" aria-label="Adjust playback speed">1×</button>
                        <button class="kg-video-unmute-icon" aria-label="Unmute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M15.189 2.021a9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h1.794a.249.249 0 0 1 .221.133 9.73 9.73 0 0 0 7.924 4.85h.06a1 1 0 0 0 1-1V3.02a1 1 0 0 0-1.06-.998Z"></path>
                            </svg>
                        </button>
                        <button class="kg-video-mute-icon kg-video-hide" aria-label="Mute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M16.177 4.3a.248.248 0 0 0 .073-.176v-1.1a1 1 0 0 0-1.061-1 9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h.114a.251.251 0 0 0 .177-.073ZM23.707 1.706A1 1 0 0 0 22.293.292l-22 22a1 1 0 0 0 0 1.414l.009.009a1 1 0 0 0 1.405-.009l6.63-6.631A.251.251 0 0 1 8.515 17a.245.245 0 0 1 .177.075 10.081 10.081 0 0 0 6.5 2.92 1 1 0 0 0 1.061-1V9.266a.247.247 0 0 1 .073-.176Z"></path>
                            </svg>
                        </button>
                        <input type="range" class="kg-video-volume-slider" max="100" value="100">
                    </div>
                </div>
            </div>
            
        </figure><p>In my case, the LLM typically took under a second to respond. Taking a look at the network waterfall, I could see the actual response time of the <code>/search</code> API route, for example:</p><figure class="kg-card kg-image-card"><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXcJFA_XYzXVjXVTqUMgxDDRrhrcdvlytl9OX4oIBTndZXQRtcFgLLUW9E9WHLufZeCVGK-yGwewDqVkYrsfYpFNC9ZpRpm3z6-cdOEoTPoFz7A2QF4XfXSMjBTZmKfp2vGhXqtU2g?key=ot3QdMEvLdRpHzVorcRQOUPH" class="kg-image" alt="" loading="lazy" width="742" height="559"></figure><p>In this particular case, the response was under 4 seconds. To be honest, that's not ideal for a real-time dashboard, but it's something that can be difficult to control when using a remote LLM.</p><p>To further improve the performance, you could consider something like <a href="https://github.com/mlc-ai/web-llm"><u>WebLLM</u></a> to run the LLM in the browser to perform this simple task. Cutting down on network times could improve performance significantly.</p><h2 id="conclusion">Conclusion</h2><p>The way we search and visualize data is changing a lot thanks to AI. There are a lot of <a href="https://www.tinybird.co/blog-posts/ai-features-that-work"><u>AI features</u></a> you can add to your application, and a simple one I've shown here is natural language filtering of real-time analytics dashboards.</p><p>If you'd like to see a complete example implementation of natural language filtering, check out the <a href="https://llm-tracker.tinybird.live/"><u>LLM Performance Tracker</u></a> by Tinybird. It's an open source template to monitor LLM usage, and it includes (as I have shown here) a feature to enable natural language filtering on LLM call data.</p><p>You can use it as a reference for your own natural language filtering project, or fork it to deploy your own LLM tracker, or just use the hosted public version if you want to track LLM usage in your application.</p><p>For example:</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAICAgAAAAAAAABBKUqGk9nLKwEMpmyKxY9OpgUwR6do2yRDgYi6egb-68VpfhuwjwTfEmcPB9opVT7SpERwjtpNEatk1C-iaCfr9rsqeyL90Vksrma-4yqJX5wm8LxbLgqUroiF0s-DWwDTfLscnMyvCXFonjiknk9crenqUVQ2JkT60yWXsmBvcNefpJs2L8x8MnprNahpYw0VhAcxAc-D3srCcHAqrxCgh-Atn9QjcaOR3YnzeRSeIs1TxYOHL8GTUrFleHaX5YW_FwyushbqR7Cr-kKrbOfAlKiy9kVyU0hVGssiFPyUSVap3mFfRI0IjJE6Dzb1gOZqH6D9WKSN7tLLMmtz2R7ix4d8EAaQKPpPcGLS2mo4ueTh4YQf-edEaeO40T4xYWG6qBTyS8DSrm51F4BD-y4ovFQeS7RmLJ4x_UlpOS37vo7sxLbyneNpgc7eCBzDZjeI7t6Ah1O1B9uv8V__O_eTAA/embed"></iframe>
<!--kg-card-end: html-->
<p>Alternatively, check out <a href="https://www.dub.co" rel="noreferrer">Dub.co</a>, an open source shortlink platform. They have a nice "Ask AI" that you can use for reference. <a href="https://github.com/dubinc/dub/tree/main" rel="noreferrer">Here's the repo</a>.</p><figure class="kg-card kg-video-card kg-width-regular kg-card-hascaption" data-kg-thumbnail="https://tinybird-blog.ghost.io/content/media/2025/04/Clipboard-20250410-143240-264_thumb.jpg" data-kg-custom-thumbnail="">
            <div class="kg-video-container">
                <video src="https://tinybird-blog.ghost.io/content/media/2025/04/Clipboard-20250410-143240-264.mp4" poster="https://img.spacergif.org/v1/1400x720/0a/spacer.png" width="1400" height="720" playsinline="" preload="metadata" style="background: transparent url('https://tinybird-blog.ghost.io/content/media/2025/04/Clipboard-20250410-143240-264_thumb.jpg') 50% 50% / cover no-repeat;"></video>
                <div class="kg-video-overlay">
                    <button class="kg-video-large-play-icon" aria-label="Play video">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                            <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"></path>
                        </svg>
                    </button>
                </div>
                <div class="kg-video-player-container">
                    <div class="kg-video-player">
                        <button class="kg-video-play-icon" aria-label="Play video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"></path>
                            </svg>
                        </button>
                        <button class="kg-video-pause-icon kg-video-hide" aria-label="Pause video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <rect x="3" y="1" width="7" height="22" rx="1.5" ry="1.5"></rect>
                                <rect x="14" y="1" width="7" height="22" rx="1.5" ry="1.5"></rect>
                            </svg>
                        </button>
                        <span class="kg-video-current-time">0:00</span>
                        <div class="kg-video-time">
                            /<span class="kg-video-duration">0:12</span>
                        </div>
                        <input type="range" class="kg-video-seek-slider" max="100" value="0">
                        <button class="kg-video-playback-rate" aria-label="Adjust playback speed">1×</button>
                        <button class="kg-video-unmute-icon" aria-label="Unmute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M15.189 2.021a9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h1.794a.249.249 0 0 1 .221.133 9.73 9.73 0 0 0 7.924 4.85h.06a1 1 0 0 0 1-1V3.02a1 1 0 0 0-1.06-.998Z"></path>
                            </svg>
                        </button>
                        <button class="kg-video-mute-icon kg-video-hide" aria-label="Mute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M16.177 4.3a.248.248 0 0 0 .073-.176v-1.1a1 1 0 0 0-1.061-1 9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h.114a.251.251 0 0 0 .177-.073ZM23.707 1.706A1 1 0 0 0 22.293.292l-22 22a1 1 0 0 0 0 1.414l.009.009a1 1 0 0 0 1.405-.009l6.63-6.631A.251.251 0 0 1 8.515 17a.245.245 0 0 1 .177.075 10.081 10.081 0 0 0 6.5 2.92 1 1 0 0 0 1.061-1V9.266a.247.247 0 0 1 .073-.176Z"></path>
                            </svg>
                        </button>
                        <input type="range" class="kg-video-volume-slider" max="100" value="100">
                    </div>
                </div>
            </div>
            <figcaption><p><span style="white-space: pre-wrap;">Dub's "Ask AI" feature</span></p></figcaption>
        </figure>
