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 (
sonarmodel) 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
The extension has three moving parts:
-
content.js— injected intoperplexity.ai, watches the search textarea, calls the background worker, renders the dropdown -
background.js— the service worker that holds the API key securely and makes fetch calls to the Sonar API -
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"
}
}
}
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", ...]
}
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);
});
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 });
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 }));
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();
}
}
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
}
});
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 }}
To release:
git tag v1.0.0
git push origin v1.0.0
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.sessionto 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.*vschrome.*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)