Skip to content

Instantly share code, notes, and snippets.

@JoeHelbing
Last active June 21, 2026 00:37
Show Gist options
  • Select an option

  • Save JoeHelbing/ec2922bfb0219d40a75622ad337c116d to your computer and use it in GitHub Desktop.

Select an option

Save JoeHelbing/ec2922bfb0219d40a75622ad337c116d to your computer and use it in GitHub Desktop.
Pi Gemini CLI websearch extension

Pi Gemini CLI Websearch Extension

A Pi coding-agent extension that registers a gemini_websearch tool and /websearch command backed by the logged-in Gemini CLI.

Files

  • gemini-websearch.ts - Pi extension source.
  • websearch-only.toml - Gemini CLI policy that allows only google_web_search for non-interactive YOLO calls.

Install

Place the files at:

~/.pi/agent/extensions/gemini-websearch.ts
~/.gemini/policies/websearch-only.toml

Ensure Gemini CLI is installed and logged in:

npm install -g @google/gemini-cli
gemini

Model order

The extension defaults to this first-attempt + fallback order for capacity/rate-limit failures:

  1. gemini-3.1-pro-preview
  2. gemini-3-flash-preview
  3. gemini-3.1-flash-lite
  4. gemini-2.5-pro
  5. gemini-2.5-flash
  6. gemini-2.5-flash-lite

The gemini-3-pro alias maps to gemini-3.1-pro-preview. If a specific model in the chain is requested, fallback starts from that point in the chain.

Security notes

  • Do not pass secrets, credentials, private URLs, local file paths, or unreleased private project details to the tool.
  • The included policy denies every Gemini CLI tool except google_web_search in non-interactive YOLO mode.
  • The extension fails closed if the policy file is missing, rather than running gemini --approval-mode=yolo unrestricted.
const DEFAULT_MODEL = "gemini-3.1-pro-preview";
const MODEL_FALLBACK_CHAIN = [
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
];
const DEFAULT_TIMEOUT_MS = 180_000;
const DEFAULT_MAX_CHARS = 80_000;
type GlobalWithProcess = typeof globalThis & {
process?: {
env?: {
HOME?: string;
};
};
};
const HOME_DIR = (globalThis as GlobalWithProcess).process?.env?.HOME ?? "";
const SEARCH_ONLY_POLICY = `${HOME_DIR}/.gemini/policies/websearch-only.toml`;
const websearchParams = {
type: "object",
properties: {
query: {
type: "string",
description: "Self-contained web search or research question. Do not include secrets, private URLs, credentials, or unreleased private details.",
},
mode: {
type: "string",
enum: ["search", "research"],
description: "Use search for a concise answer; research for deeper source-backed notes. Defaults to research.",
},
model: {
type: "string",
description: "Preferred Gemini CLI model. Defaults to gemini-3.1-pro-preview. The alias gemini-3-pro is mapped to gemini-3.1-pro-preview; capacity/rate-limit failures devolve through gemini-3-flash-preview, gemini-3.1-flash-lite, gemini-2.5-pro, gemini-2.5-flash, and gemini-2.5-flash-lite.",
},
timeoutMs: {
type: "number",
description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS}.`,
},
maxChars: {
type: "number",
description: `Maximum characters returned to the model. Defaults to ${DEFAULT_MAX_CHARS}.`,
},
},
required: ["query"],
additionalProperties: false,
} as const;
type WebsearchParams = {
query: string;
mode?: "search" | "research";
model?: string;
timeoutMs?: number;
maxChars?: number;
};
function normalizeModel(model?: string): string {
const trimmed = model?.trim();
if (!trimmed || trimmed === "gemini-3-pro") return DEFAULT_MODEL;
return trimmed;
}
export function buildModelChain(model?: string): string[] {
const preferredModel = normalizeModel(model);
const preferredIndex = MODEL_FALLBACK_CHAIN.indexOf(preferredModel);
const lowerModels = preferredIndex >= 0
? MODEL_FALLBACK_CHAIN.slice(preferredIndex + 1)
: MODEL_FALLBACK_CHAIN;
return [preferredModel, ...lowerModels].filter((candidate, index, allModels) => allModels.indexOf(candidate) === index);
}
export function isFallbackableGeminiFailure(stdout: string, stderr: string, model: string): boolean {
const combined = `${stdout}\n${stderr}`;
if (/ModelNotFoundError/i.test(combined)) return false;
if (/MODEL_CAPACITY_EXHAUSTED/i.test(combined)) return true;
if (/No capacity available for model/i.test(combined) && combined.includes(model)) return true;
if (/RESOURCE_EXHAUSTED/i.test(combined) && /\b429\b/.test(combined)) return true;
return false;
}
function buildPrompt(query: string, mode: "search" | "research"): string {
const depth = mode === "search"
? "Answer concisely. Use 2-4 key findings unless more are necessary."
: "Do deeper web research. Compare sources, note conflicts, and include enough detail for another agent to use the result.";
return `You are a web research subagent being called by the local Pi coding agent.
Use the google_web_search tool when the answer depends on current, niche, source-backed, or uncertain public information.
Do not use local filesystem tools, shell tools, code execution, memory tools, MCP tools, web_fetch, or file editing tools.
${depth}
Return Markdown only, using this structure:
## Direct answer
A concise answer to the research question.
## Key findings
- Important finding with source URL.
- Important finding with source URL.
## Sources
- Source title -- URL -- what it supports.
## Caveats
- Note uncertainty, stale data risk, or conflicts between sources.
Research question:
${query}`;
}
function extractGeminiResponse(stdout: string): string {
const text = stdout.trim();
if (!text) return "";
try {
const parsed = JSON.parse(text);
if (typeof parsed === "string") return parsed;
if (parsed && typeof parsed === "object") {
const object = parsed as Record<string, unknown>;
for (const key of ["response", "text", "output", "content", "message"]) {
if (typeof object[key] === "string") return object[key] as string;
}
}
} catch {
// Fall back to raw stdout below.
}
return text;
}
function truncate(text: string, maxChars: number): { text: string; truncated: boolean } {
if (text.length <= maxChars) return { text, truncated: false };
return {
text: `${text.slice(0, maxChars)}\n\n[gemini_websearch output truncated at ${maxChars} characters]`,
truncated: true,
};
}
async function fileExists(pi: any, path: string, signal?: AbortSignal): Promise<boolean> {
try {
const result = await pi.exec("test", ["-f", path], {
signal,
timeout: 1_000,
});
return result.code === 0;
} catch {
return false;
}
}
export default function geminiWebsearchExtension(pi: any) {
pi.registerTool({
name: "gemini_websearch",
label: "Gemini Websearch",
description:
"Search or research the public web with the logged-in Gemini CLI using Gemini 3.1 Pro with Gemini 3/2.5 fallback and a search-only policy. Returns Markdown with source URLs.",
promptSnippet:
"Search or research current public web information through Gemini CLI (Gemini 3.1 Pro with Gemini 3/2.5 fallback) with source URLs.",
promptGuidelines: [
"Use gemini_websearch when current, niche, source-backed, or post-cutoff public information is needed and a normal page extraction tool is insufficient.",
"Do not pass secrets, credentials, private URLs, local file paths, or unreleased private project details to gemini_websearch.",
"Treat gemini_websearch output as research notes, preserve source URLs when relevant, and verify important claims with narrower follow-up searches when needed.",
],
parameters: websearchParams,
async execute(_toolCallId: string, params: WebsearchParams, signal: AbortSignal | undefined, onUpdate: ((value: unknown) => void) | undefined, ctx: any) {
const query = params.query.trim();
if (!query) {
return {
content: [{ type: "text", text: "gemini_websearch requires a non-empty query." }],
details: { ok: false, reason: "empty_query" },
isError: true,
};
}
const mode = params.mode ?? "research";
const modelChain = buildModelChain(params.model);
const timeout = Math.max(10_000, Math.floor(params.timeoutMs ?? DEFAULT_TIMEOUT_MS));
const maxChars = Math.max(1_000, Math.floor(params.maxChars ?? DEFAULT_MAX_CHARS));
const prompt = buildPrompt(query, mode);
const hasSearchOnlyPolicy = await fileExists(pi, SEARCH_ONLY_POLICY, signal);
if (!hasSearchOnlyPolicy) {
return {
content: [{ type: "text", text: `Gemini websearch policy file not found at ${SEARCH_ONLY_POLICY}. Refusing to run Gemini CLI with --approval-mode=yolo without a search-only policy.` }],
details: { ok: false, query, mode, policy: SEARCH_ONLY_POLICY, reason: "missing_search_only_policy" },
isError: true,
};
}
const attemptedModels: Array<{ model: string; code: number; fallbackable: boolean }> = [];
try {
for (const [index, model] of modelChain.entries()) {
const args = [
"--prompt", prompt,
"--skip-trust",
"--approval-mode=yolo",
"--output-format", "json",
"--model", model,
];
if (hasSearchOnlyPolicy) {
args.push("--policy", SEARCH_ONLY_POLICY);
}
onUpdate?.({ content: [{ type: "text", text: `Searching the web with Gemini CLI (${model})...` }] });
const result = await pi.exec("gemini", args, {
cwd: ctx.cwd,
signal,
timeout,
});
const fallbackable = result.code !== 0 && index < modelChain.length - 1 && isFallbackableGeminiFailure(result.stdout, result.stderr, model);
attemptedModels.push({ model, code: result.code, fallbackable });
if (fallbackable) {
onUpdate?.({ content: [{ type: "text", text: `Gemini CLI capacity/rate-limit failure on ${model}; falling back to ${modelChain[index + 1]}...` }] });
continue;
}
const response = extractGeminiResponse(result.stdout);
const rawText = response || result.stderr.trim() || "Gemini CLI returned no output.";
const { text, truncated } = truncate(rawText, maxChars);
const ok = result.code === 0;
return {
content: [{ type: "text", text }],
details: {
ok,
code: result.code,
query,
mode,
model,
requestedModel: normalizeModel(params.model),
modelChain,
attemptedModels,
fallbackUsed: index > 0,
policy: hasSearchOnlyPolicy ? SEARCH_ONLY_POLICY : undefined,
truncated,
stdoutLength: result.stdout.length,
stderr: result.stderr,
},
isError: !ok,
};
}
return {
content: [{ type: "text", text: "Gemini CLI returned no output." }],
details: { ok: false, query, mode, modelChain, attemptedModels },
isError: true,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const installHint = message.includes("ENOENT") || message.includes("not found")
? "\n\nInstall and log in to Gemini CLI first: npm install -g @google/gemini-cli && gemini"
: "";
return {
content: [{ type: "text", text: `Gemini websearch failed: ${message}${installHint}` }],
details: { ok: false, query, mode, modelChain, attemptedModels, error: message },
isError: true,
};
}
},
});
pi.registerCommand("websearch", {
description: "Ask Gemini CLI to search/research the web: /websearch <query>",
handler: async (args: string, ctx: any) => {
const query = args.trim();
if (!query) {
ctx.ui.notify("Usage: /websearch <query>", "warning");
return;
}
const message = `Use gemini_websearch in research mode for this query: ${query}`;
if (ctx.isIdle()) {
pi.sendUserMessage(message);
} else {
pi.sendUserMessage(message, { deliverAs: "followUp" });
}
},
});
}
# Restrict non-interactive Gemini CLI research calls to Google web search only.
# Intended for Pi's gemini_websearch tool, which invokes Gemini with
# --approval-mode=yolo and this policy file.
[[rule]]
toolName = "google_web_search"
decision = "allow"
priority = 999
modes = ["yolo"]
interactive = false
[[rule]]
toolName = "*"
decision = "deny"
priority = 998
modes = ["yolo"]
interactive = false
denyMessage = "Denied: Pi's Gemini websearch tool may only use google_web_search."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment