DEV Community

Cover image for AI-Powered Autocomplete for Perplexity Using Sonar API
Ravi Sastry Kadali
Ravi Sastry Kadali

Posted on

AI-Powered Autocomplete for Perplexity Using Sonar API

If you've used Perplexity.ai, you already know it's one of the best AI search engines out there. But there's one thing missing that Google has had for decades: autocomplete.
As you type in Google's search bar, suggestions appear instantly. Perplexity doesn't have this — you're typing blind until you hit Enter.

So I built it. A Chrome extension that injects AI-powered query suggestions directly into Perplexity's search bar, powered by their own Sonar API.

Here's exactly how I did it.

What It Does

As you type in the Perplexity search bar (3+ characters), a dropdown appears with 5 AI-generated query completions. You can navigate them with arrow keys, select with Enter, or dismiss with Escape.

It looks and feels native to Perplexity's dark UI.

Demo: Watch it in action

GitHub: ravisastryk/perplexity-autocomplete


Tech Stack

  • Manifest V3 Chrome Extension
  • Perplexity Sonar API (sonar model) for generating suggestions
  • Vanilla JS — no framework needed for a content script this focused
  • GitHub Actions + GitHub Packages for CI/CD and automated releases

Architecture Overview

User types → content.js (debounced 350ms)
                 ↓
         background.js (service worker)
                 ↓
         Perplexity Sonar API
                 ↓
         JSON array of 5 suggestions
                 ↓
         Dropdown rendered in Perplexity's DOM
Enter fullscreen mode Exit fullscreen mode

The extension has three moving parts:

  1. content.js — injected into perplexity.ai, watches the search textarea, calls the background worker, renders the dropdown
  2. background.js — the service worker that holds the API key securely and makes fetch calls to the Sonar API
  3. popup.html/js — a small settings UI where users paste their Perplexity API key

Step 1: The Manifest

Chrome Manifest V3 requires service workers instead of background pages, and explicit host permissions for any domains you fetch from.

{
  "manifest_version": 3,
  "name": "Perplexity AutoComplete",
  "version": "1.0.0",
  "permissions": ["activeTab", "storage"],
  "host_permissions": [
    "https://www.perplexity.ai/*",
    "https://api.perplexity.ai/*"
  ],
  "content_scripts": [
    {
      "matches": ["https://www.perplexity.ai/*"],
      "js": ["content.js"],
      "css": ["styles.css"]
    }
  ],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icon16.png",
      "48": "icon48.png",
      "128": "icon128.png"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

One thing to watch: the host_permissions for both perplexity.ai (where the content script runs) and api.perplexity.ai (where the API calls go) must both be declared, or Chrome will block the fetch silently.


Step 2: Calling the Sonar API

The Perplexity API is OpenAI-compatible — you hit /v1/chat/completions with a model name and a messages array. The trick is prompting it to return structured JSON:

async function fetchSuggestions(query) {
  const response = await fetch("https://api.perplexity.ai/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${apiKey}`
    },
    body: JSON.stringify({
      model: "sonar",
      max_tokens: 200,
      messages: [
        {
          role: "system",
          content: `You are a search query autocomplete engine.
Given a partial query, return exactly 5 complete search query suggestions as a JSON array.
Return ONLY valid JSON like: ["suggestion 1", "suggestion 2", ...]. No explanation.`
        },
        {
          role: "user",
          content: `Partial query: "${query}"`
        }
      ]
    })
  });

  const data = await response.json();
  const text = data.content[0].text.trim();
  return JSON.parse(text); // ["suggestion 1", ...]
}
Enter fullscreen mode Exit fullscreen mode

I chose the sonar model over sonar-pro for cost efficiency — this runs on every keystroke (debounced), so the cheaper model keeps API costs minimal (~$0.01–0.05/day at normal usage).


Step 3: The Debounce Problem

Without debouncing, every keystroke fires an API call. Type "what is" (7 characters) = 7 API calls, 6 of which are wasted.

The solution is a classic debounce — wait until the user pauses:

let debounceTimer = null;

searchInput.addEventListener("input", (e) => {
  const query = e.target.value.trim();
  clearTimeout(debounceTimer);

  if (query.length < 3) {
    hideDropdown();
    return;
  }

  debounceTimer = setTimeout(() => {
    // Only fires if user paused typing for 350ms
    chrome.runtime.sendMessage(
      { type: "GET_SUGGESTIONS", query },
      (response) => showSuggestions(response.suggestions, e.target)
    );
  }, 350);
});
Enter fullscreen mode Exit fullscreen mode

350ms feels instant to users but cuts API calls by ~80% compared to firing on every keypress.


Step 4: Injecting Into Perplexity's DOM

Here's the tricky part. Perplexity is a React app — the DOM is dynamic. The search textarea isn't always there on page load, and navigating between pages doesn't trigger a full reload.

I solved this with a MutationObserver that watches for new textareas:

function attachAutoComplete(searchInput) {
  if (searchInput.dataset.pplxAcAttached) return; // avoid double-attaching
  searchInput.dataset.pplxAcAttached = "true";
  // ... attach listeners
}

const observer = new MutationObserver(() => {
  document.querySelectorAll("textarea").forEach((el) => {
    if (!el.dataset.pplxAcAttached) attachAutoComplete(el);
  });
});

observer.observe(document.body, { childList: true, subtree: true });
Enter fullscreen mode Exit fullscreen mode

The dataset.pplxAcAttached guard prevents attaching duplicate listeners when React re-renders the component.

Setting a value on React-controlled inputs also requires triggering the synthetic event system — otherwise React ignores the update:

const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
  window.HTMLTextAreaElement.prototype, "value"
)?.set;

nativeInputValueSetter.call(inputEl, selectedValue);
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
Enter fullscreen mode Exit fullscreen mode

Step 5: Keyboard Navigation

A dropdown without keyboard support is frustrating. I added full arrow key navigation:

function handleKeydown(e, inputEl) {
  if (dropdown.style.display === "none") return;

  if (e.key === "ArrowDown") {
    e.preventDefault();
    setActiveItem(Math.min(activeIndex + 1, suggestions.length - 1));
  } else if (e.key === "ArrowUp") {
    e.preventDefault();
    setActiveItem(Math.max(activeIndex - 1, -1));
  } else if (e.key === "Enter" && activeIndex >= 0) {
    e.preventDefault();
    applySelection(suggestions[activeIndex], inputEl);
  } else if (e.key === "Escape") {
    hideDropdown();
  }
}
Enter fullscreen mode Exit fullscreen mode

The e.preventDefault() on arrow keys is critical — without it, the cursor jumps to the start/end of the textarea instead of moving through suggestions.


Step 6: Securing the API Key

The API key lives in chrome.storage.local — not hardcoded, not in localStorage (which content scripts can read from any site). The service worker retrieves it on each API call:

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.type === "GET_SUGGESTIONS") {
    fetchSuggestions(request.query).then(sendResponse);
    return true; // keep async channel open
  }
});
Enter fullscreen mode Exit fullscreen mode

The content script never touches the API key directly — it only sends messages to the background worker, which is isolated from the page's JavaScript context.


Step 7: CI/CD with GitHub Actions + GitHub Packages

I automated releases using GitHub Actions. Pushing a version tag triggers the full pipeline:

on:
  push:
    tags:
      - 'v*.*.*'

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://npm.pkg.github.com'
          scope: '@ravisastryk'

      - name: Sync version from tag
        run: npm version ${GITHUB_REF_NAME#v} --no-git-tag-version

      - name: Package extension zip
        run: zip -r perplexity-autocomplete-${GITHUB_REF_NAME}.zip manifest.json background.js content.js styles.css popup.html popup.js icon*.png logo.svg README.md

      - name: Publish to GitHub Packages
        run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: perplexity-autocomplete-${{ github.ref_name }}.zip
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

To release:

git tag v1.0.0
git push origin v1.0.0
Enter fullscreen mode Exit fullscreen mode

That's it. GitHub Actions handles packaging, publishing to GitHub Packages (npm registry), and creating the release with the zip attached.


API Cost Reality Check

I was worried this would get expensive. It didn't.

Usage Estimated cost
100 searches/day ~$0.01–0.05
1,000 searches/day ~$0.10–0.50

The 350ms debounce and 3-character minimum do most of the heavy lifting here.


What I'd Build Next

  • Caching — store recent suggestion results in chrome.storage.session to avoid repeat API calls for the same prefix
  • Personalization — use the user's search history to bias suggestions toward their common topics
  • Submit to Chrome Web Store — currently load-unpacked only; submitting for public distribution is the next step
  • Firefox port — Manifest V3 is largely compatible; mainly the browser.* vs chrome.* namespace difference

Try It Yourself

The full source is on GitHub with step-by-step installation instructions:

github.com/ravisastryk/perplexity-autocomplete

You'll need a Perplexity API key from perplexity.ai/settings/api — the free tier gives you enough to experiment.

Happy to answer questions in the comments. If you build on top of this or find a better prompting strategy for the suggestions, open a PR.

Top comments (0)