9.4 KiB
pi-perplexity Architecture
Overview
An oh-my-pi plugin that provides web search via a Perplexity Pro/Max subscription. Uses OAuth JWT authentication against Perplexity's internal SSE endpoint — no API credits consumed, only the subscription.
System Context
oh-my-pi (coding-agent)
|
+-- plugin loader (discovers pi-perplexity via package.json "omp" manifest)
|
+-- pi-perplexity (CustomToolFactory)
|
+-- perplexity_search tool (CustomTool)
| |
| +-- Auth: JWT from macOS app or email OTP
| +-- Search: POST SSE to www.perplexity.ai
| +-- Parse: incremental event merging
| +-- Render: TUI components for call/result
|
+-- Token storage (SQLite via omp's AgentStorage, or standalone file)
Authentication
JWT Acquisition (two paths, tried in order)
Path 1 — macOS Desktop App Extraction (zero-interaction)
The Perplexity macOS Catalyst app (ai.perplexity.mac) stores its auth JWT in NSUserDefaults, readable by any same-UID process:
defaults read ai.perplexity.mac authToken
If the app is installed and logged in, this returns the JWT immediately. No browser, no user interaction.
Skip this path if PI_AUTH_NO_BORROW=1 is set.
Path 2 — Email OTP (interactive, fallback)
GET https://www.perplexity.ai/api/auth/csrf
-> { csrfToken: string }
POST https://www.perplexity.ai/api/auth/signin-email
body: { email, csrfToken }
-> Sends OTP code to email
(user enters OTP)
POST https://www.perplexity.ai/api/auth/signin-otp
body: { email, otp, csrfToken }
-> { token: "<JWT>" }
All auth requests use these headers:
User-Agent: Perplexity/641 CFNetwork/1568 Darwin/25.2.0
X-App-ApiVersion: 2.18
JWT Handling
- Expiry extracted from JWT payload
expclaim:decoded.exp * 1000 - 5min - Fallback expiry: 1 hour from acquisition if decode fails
- No automated refresh — Perplexity JWTs are long-lived; re-login on expiry
- Storage: persisted to disk so login survives restarts
Token Storage
The JWT is stored as:
{
type: "oauth",
access: "<JWT>",
expires: <exp_ms_minus_5min>,
email?: "<user@example.com>"
}
Two storage strategies (choose during implementation):
- Integrate with omp's AgentStorage — query
listAuthCredentials("perplexity")from the agent.db SQLite. Requires access to the db path viagetAgentDbPath(). - Standalone JSON file —
~/.config/pi-perplexity/auth.json. Simpler, no dependency on omp internals, portable.
On search, check stored JWT expiry (with 5-minute buffer). If expired, prompt re-login.
Search Protocol
Endpoint
POST https://www.perplexity.ai/rest/sse/perplexity_ask
Request
Headers:
Authorization: Bearer <JWT>
Content-Type: application/json
Accept: text/event-stream
Origin: https://www.perplexity.ai
Referer: https://www.perplexity.ai/
User-Agent: Perplexity/641 CFNetwork/1568 Darwin/25.2.0
X-App-ApiClient: default
X-App-ApiVersion: 2.18
X-Perplexity-Request-Reason: submit
X-Request-ID: <random-uuid>
Body:
{
"query_str": "<effective_query>",
"params": {
"query_str": "<effective_query>",
"search_focus": "internet",
"mode": "copilot",
"model_preference": "pplx_pro_upgraded",
"sources": ["web"],
"attachments": [],
"frontend_uuid": "<random-uuid>",
"frontend_context_uuid": "<random-uuid>",
"version": "2.18",
"language": "en-US",
"timezone": "<Intl.DateTimeFormat().resolvedOptions().timeZone>",
"search_recency_filter": null | "hour" | "day" | "week" | "month" | "year",
"is_incognito": true,
"use_schematized_api": true,
"skip_search_enabled": true
}
}
Key parameters:
model_preference: "pplx_pro_upgraded"— Pro subscription modelmode: "copilot"— multi-step reasoning (Pro feature)is_incognito: true— does not save to Perplexity historyeffective_query— system prompt prepended to user query with\n\nseparator (no separate system message in this API)
Response: SSE Event Stream
Each data: line contains a JSON event. Events are incremental snapshots that must be merged.
Event Shape
interface StreamEvent {
status?: string; // "COMPLETED" on final
final?: boolean; // true on last event
text?: string; // plain text answer (fallback)
blocks?: StreamBlock[]; // structured content blocks
sources_list?: StreamSource[];// source references (alternative format)
display_model?: string; // model name used
uuid?: string; // request ID
error_code?: string;
error_message?: string;
}
interface StreamBlock {
intended_usage?: string; // "markdown_block" | "ask_text" | "web_results"
markdown_block?: {
answer?: string;
chunks?: string[]; // incremental text chunks
chunk_starting_offset?: number;
};
web_result_block?: {
web_results?: WebResult[];
};
}
interface WebResult {
name?: string;
url?: string;
snippet?: string;
timestamp?: string;
}
interface StreamSource {
title?: string;
url?: string;
snippet?: string;
date?: string;
}
Event Merging Strategy
Events are incremental — each new event is merged into a running snapshot:
- Top-level fields: shallow merge (
{ ...existing, ...incoming }) - Blocks: keyed by
intended_usage— new blocks with same key replace/merge with existing - Markdown chunks: if
chunk_starting_offsetis 0, replace all chunks; otherwise splice at offset - Sources: accumulated, not replaced;
sources_listpreserved from earlier events if absent in later ones
Stream terminates when event.final === true or event.status === "COMPLETED".
Answer Extraction (priority order)
blockswhereintended_usagecontains"markdown"-> joinchunks[]or useanswerfieldblockswhereintended_usage === "ask_text"-> same logicevent.textfallback
Source Extraction (priority order)
blockswhereintended_usage === "web_results"->web_result_block.web_results[]event.sources_list[]fallback- Deduplicate by URL
Plugin Interface
oh-my-pi CustomTool Contract
The plugin exports a CustomToolFactory:
type CustomToolFactory = (api: CustomToolAPI) =>
CustomTool | CustomTool[] | Promise<CustomTool | CustomTool[]>;
Each CustomTool implements:
interface CustomTool<TParams, TDetails> {
name: string; // "perplexity_search"
label: string; // "Perplexity Search"
description: string; // tool description for LLM
parameters: TSchema; // TypeBox schema
execute(toolCallId, params, onUpdate, ctx, signal): Promise<AgentToolResult>;
renderCall?(args, theme): Component; // TUI call display
renderResult?(result, options, theme): Component; // TUI result display
}
Tool Parameters
{
query: string; // required
recency?: "hour" | "day" | "week" | "month" | "year";
limit?: number; // max sources to return
}
Tool Output
Text block formatted for LLM consumption:
## Answer
<synthesized answer with inline citations>
## Sources
N sources
[1] Title (age)
https://url
snippet...
## Meta
Provider: perplexity (oauth)
Model: <display_model>
Package Structure
pi-perplexity/
package.json # omp plugin manifest
tsconfig.json
src/
index.ts # CustomToolFactory entry point
auth/
jwt.ts # JWT decode, expiry extraction
login.ts # macOS app extraction + email OTP flow
storage.ts # Token persistence (read/write/check expiry)
search/
client.ts # HTTP request to SSE endpoint
stream.ts # SSE parsing + event merging
types.ts # All type definitions
format.ts # Response formatting for LLM output
render/
call.ts # TUI renderCall component
result.ts # TUI renderResult component
Dependencies
Required
@sinclair/typebox— injected by omp viaCustomToolAPI.typebox, no direct dependency needed@oh-my-pi/pi-tui— for TUIComponenttype in renderers (peer dependency)
None (zero runtime dependencies)
fetch— global (Bun/Node 18+)crypto.randomUUID()— globalatob/Buffer— globalBun.$— fordefaults readon macOSIntl.DateTimeFormat— global
The plugin should have zero npm dependencies. All HTTP, SSE parsing, and JWT decoding use platform APIs.
Error Handling
| Error | Behavior |
|---|---|
| No JWT found (not logged in) | Return error text to agent: "Not authenticated. Run omp login perplexity." |
| JWT expired | Attempt re-login via macOS app extraction; if fails, return error prompting manual login |
| HTTP 401/403 | JWT revoked or expired; clear stored token, return auth error |
| HTTP 429 | Rate limited; return error with retry suggestion |
SSE error_code in stream |
Extract error_message, throw SearchError |
| Network failure | Return error text to agent |
| Empty response (no answer, no sources) | Return "No results found" |
Security Considerations
- JWT stored on disk with user-only permissions (0600)
is_incognito: trueprevents queries from appearing in Perplexity history- No API key needed — subscription auth only
- Token never logged; only expiry metadata logged at debug level