Skip to main content
Pi Agent
Pi Agent Practice Guide 的文章封面图

Pi Agent Practice Guide

AI-assisted

Implement DeepSearch capability with Pi Extension: enabling Pi to break down problems, retrieve information, organize evidence, and generate research conclusions with sources.

Quick Recap

In the conceptual article, I defined Pi Agent as a minimalist Agent Harness: it connects models, terminals, file systems, shells, sessions, and the extension system, but doesn't pre-configure a heavy workflow for you.

So, in this practice guide, I don't want to create another typical "let Pi modify files" example. While that example demonstrates the basic loop, it doesn't fully showcase Pi's extensibility.

A more suitable practical example for Pi is to add a capability it doesn't have by default, but many people genuinely need: DeepSearch.

Here, DeepSearch is not just simple online searching, but a research-oriented workflow:

StageWhat to do
Problem DecompositionBreak down a vague question into several searchable sub-questions
Multi-round RetrievalSearch official documentation, code repositories, blogs, discussion forums, or papers
Source FilteringDeduplicate, exclude low-quality results, prioritize primary sources
Evidence OrganizationExtract key facts, links, dates, versions, and uncertainties
Synthesized AnswerProvide conclusions, along with supporting evidence and limitations

My judgment is: DeepSearch should not be built into Pi's core, nor should it rely solely on prompt engineering. It's better suited as a Pi Extension.

The reason is simple: DeepSearch involves network requests, third-party search APIs, source filtering, result truncation, citation formatting, and security boundaries. These are all workflow capabilities, not part of the coding agent's minimal core.

Design Goals

This example aims to implement a minimal viable version, not a perfect research system.

The goals are as follows:

Add a deep_search tool to Pi.

It accepts:
- query: The user's research question
- depth: Search depth
- maxResults: Maximum number of candidate sources to return

It outputs:
- Structured search results
- Title, URL, snippet, and relevance score for each result
- Evidence prompts for the model

After Pi receives this evidence, the current model will generate the final conclusion.

I will deliberately separate "retrieval" and "synthesis":

PartResponsible PartyReason
Search API callsDeepSearch extensionThis is a deterministic external capability
Result deduplication and truncationDeepSearch extensionAvoid context being filled with noise
Judging which evidence is importantPi's current modelRequires reasoning and context understanding
Final answer writingPi's current modelNeeds to combine user questions and project context

This approach is more robust. The extension doesn't need to call another model itself, nor does it need to become a nested agent. It only provides high-quality evidence, allowing Pi's original model to continue reasoning.

Preparation

Pi extensions can be placed in a global directory or a project directory. Here, I recommend placing them in the project directory first:

.pi/extensions/deepsearch/
  package.json
  index.ts

The advantage of a project-local extension is clear boundaries. This DeepSearch capability is only enabled within the current project and will not affect all Pi sessions.

For the search service, you can choose Tavily, Exa, Brave Search, SerpAPI, or even your own search backend. For the first version, don't worry about the service provider; first abstract it into a searchWeb() function.

For example, save the API Key as an environment variable:

export TAVILY_API_KEY=tvly-...

If you don't want to connect to a third-party search API, you can also use local mock data to get the extension running first. Once tool registration, parameter passing, and result formatting are stable, then connect to a real search service.

Step 1: Create the Extension Directory

First, create the directory:

mkdir -p .pi/extensions/deepsearch

If the extension needs dependencies, you can include a package.json:

{
  "name": "pi-deepsearch-extension",
  "private": true,
  "dependencies": {
    "typebox": "*",
    "@earendil-works/pi-ai": "*",
    "@earendil-works/pi-coding-agent": "*"
  },
  "pi": {
    "extensions": ["./index.ts"]
  }
}

Then install dependencies:

cd .pi/extensions/deepsearch
npm install

Pi's extensions are TypeScript modules; you don't need to compile them manually first. This experience is great for quickly experimenting with tools.

Step 2: Register the deep_search Tool

The core file is .pi/extensions/deepsearch/index.ts.

The first version can be written like this:

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { StringEnum } from "@earendil-works/pi-ai";
import { Type } from "typebox";

type SearchResult = {
  title: string;
  url: string;
  snippet: string;
  score?: number;
};

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "deep_search",
    label: "DeepSearch",
    description: "Search the web for source-backed evidence about a question.",
    promptSnippet: "Research a question with web search and return source-backed evidence.",
    promptGuidelines: [
      "Use deep_search when the user asks for current facts, external sources, comparison, investigation, or source-backed research.",
      "After deep_search returns results, synthesize an answer with citations and clearly separate facts, inference, and uncertainty.",
      "Do not treat deep_search results as final truth; inspect source quality and mention gaps."
    ],
    parameters: Type.Object({
      query: Type.String({
        description: "The research question or search query."
      }),
      depth: Type.Optional(StringEnum(["quick", "normal", "deep"] as const)),
      maxResults: Type.Optional(Type.Number({
        minimum: 3,
        maximum: 10,
        default: 6
      }))
    }),
    async execute(_toolCallId, params, signal) {
      const depth = params.depth ?? "normal";
      const maxResults = params.maxResults ?? 6;
      const results = await searchWeb(params.query, depth, maxResults, signal);

      return {
        content: [
          {
            type: "text",
            text: formatResultsForModel(params.query, results)
          }
        ],
        details: {
          query: params.query,
          depth,
          results
        }
      };
    }
  });
}

async function searchWeb(
  query: string,
  depth: "quick" | "normal" | "deep",
  maxResults: number,
  signal: AbortSignal
): Promise<SearchResult[]> {
  const apiKey = process.env.TAVILY_API_KEY;
  if (!apiKey) {
    throw new Error("Missing TAVILY_API_KEY. Set it before starting pi.");
  }

  const response = await fetch("https://api.tavily.com/search", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      api_key: apiKey,
      query,
      search_depth: depth === "quick" ? "basic" : "advanced",
      max_results: maxResults,
      include_answer: false,
      include_raw_content: depth === "deep"
    }),
    signal
  });

  if (!response.ok) {
    throw new Error(`Search failed: ${response.status} ${response.statusText}`);
  }

  const data = await response.json() as {
    results?: Array<{
      title?: string;
      url?: string;
      content?: string;
      score?: number;
    }>;
  };

  return dedupeByUrl((data.results ?? []).map((item) => ({
    title: item.title ?? "Untitled",
    url: item.url ?? "",
    snippet: item.content ?? "",
    score: item.score
  }))).filter((item) => item.url);
}

function dedupeByUrl(results: SearchResult[]): SearchResult[] {
  const seen = new Set<string>();
  const deduped: SearchResult[] = [];

  for (const result of results) {
    const key = normalizeUrl(result.url);
    if (seen.has(key)) continue;
    seen.add(key);
    deduped.push(result);
  }

  return deduped;
}

function normalizeUrl(url: string): string {
  try {
    const parsed = new URL(url);
    parsed.hash = "";
    parsed.searchParams.delete("utm_source");
    parsed.searchParams.delete("utm_medium");
    parsed.searchParams.delete("utm_campaign");
    return parsed.toString();
  } catch {
    return url;
  }
}

function formatResultsForModel(query: string, results: SearchResult[]): string {
  if (results.length === 0) {
    return `DeepSearch found no results for: ${query}`;
  }

  const lines = results.map((result, index) => {
    return [
      `## Source ${index + 1}`,
      `Title: ${result.title}`,
      `URL: ${result.url}`,
      result.score === undefined ? undefined : `Score: ${result.score}`,
      `Snippet: ${result.snippet}`
    ].filter(Boolean).join("\n");
  });

  return [
    `DeepSearch query: ${query}`,
    "",
    "Use these sources as evidence. Cite URLs when making factual claims.",
    "Separate confirmed facts from inference and uncertainty.",
    "",
    ...lines
  ].join("\n\n");
}

This code only does the most critical things:

Code LocationPurpose
pi.registerTool()Exposes deep_search for model calls
parametersTells the model what parameters the tool needs
promptGuidelinesTells the model when to use it and how to handle results
searchWeb()Calls the real search service
dedupeByUrl()Removes duplicate URLs
formatResultsForModel()Organizes search results into evidence blocks that are easy for the model to cite

The first version should not be too complex. The real challenge of DeepSearch is controlling source quality, context length, citation format, and uncertainty.

Step 3: Add a /deepsearch Command

Tools are for models to call, but users also need a direct entry point.

You can register another command to rewrite user input into a more explicit research task:

export default function (pi: ExtensionAPI) {
  pi.registerCommand("deepsearch", {
    description: "Run a source-backed DeepSearch task",
    handler: async (args, ctx) => {
      const query = String(args ?? "").trim();

      if (!query) {
        ctx.ui.notify("Usage: /deepsearch <question>", "warning");
        return;
      }

      pi.sendUserMessage(
        [
          "Please perform a DeepSearch on the following question.",
          "",
          `Question: ${query}`,
          "",
          "Requirements:",
          "1. First, determine if deep_search needs to be called.",
          "2. If the question is complex, break it down into 2-4 sub-questions and retrieve information for each.",
          "3. The final answer must include source links.",
          "4. Distinguish between facts, inferences, and uncertain parts.",
          "5. Do not just list search results; provide a comprehensive judgment."
        ].join("\n"),
        { deliverAs: "followUp" }
      );
    }
  });

  pi.registerTool({
    // deep_search tool definition...
  });
}

This way, users can directly input:

/deepsearch What capabilities does the latest version of Pi Coding Agent's extension system support?

/deepsearch doesn't search directly, but sends Pi a more complete task description. The model will call deep_search based on the description, and then synthesize the results.

I prefer this design because it preserves the agent's judgment space. The search tool is just an entry point for evidence, not the final answer generator.

Step 4: Launch and Verify

Once the project-local extension is in place, you can launch Pi directly from the project root:

TAVILY_API_KEY=tvly-... pi

If you are just testing temporarily, you can explicitly specify the extension:

TAVILY_API_KEY=tvly-... pi -e ./.pi/extensions/deepsearch/index.ts

After entering Pi, ask a question that requires external facts:

/deepsearch What capabilities does the latest version of Pi Coding Agent's extension system support?

An acceptable output should not just be a few search results, but should include:

CheckpointAcceptable Performance
Tool invocationdeep_search is called
Clear sourcesEach key fact is followed by a URL
DeduplicationNo repeated citations of the same page
JudgmentNot just listing information, but also summarizing applicable scenarios
UncertaintyMaintains boundaries for version changes, third-party APIs, and community extensions

If the result is just a "list of search results," it means the promptGuidelines are not strong enough. You can make the guidelines more explicit:

promptGuidelines: [
  "Use deep_search to gather evidence, not to produce the final answer.",
  "After deep_search, write a concise research brief with citations.",
  "Prefer official documentation, source code, release notes, and primary sources.",
  "Mention when sources disagree or when the evidence is incomplete."
]

Step 5: Make DeepSearch More Like a Research Tool

After getting the first version running, you can continue to add three types of capabilities.

Sub-problem Decomposition

The most common failure point for DeepSearch is throwing a large question directly at the search API.

For example:

Can Pi Agent replace Claude Code?

This is not a good search query. It can at least be broken down into:

Sub-questionPurpose
What is the core design of Pi AgentFind its positioning
What tools and extensions does Pi Agent supportFind its capability boundaries
What are Claude Code's default capabilitiesFind comparison objects
What are the differences in permissions, security, and extensibility between the twoForm a judgment

The first version can let the model decompose itself; the second version can make the /deepsearch command force the model to first list sub-questions, then call deep_search for each.

Source Quality Layering

DeepSearch output should not just be sorted by the search API's score. When writing technical articles, I prioritize:

PrioritySource
P0Official documentation, source code, release notes
P1Author's blog, maintainer's explanation, issue / PR
P2High-quality tutorials, technical analysis
P3Community discussions, Reddit, X, forums

The extension can annotate source types in formatResultsForModel():

function classifySource(url: string): "official" | "source" | "community" | "other" {
  const host = new URL(url).hostname;
  if (host === "pi.dev") return "official";
  if (host === "github.com") return "source";
  if (host.includes("reddit.com")) return "community";
  return "other";
}

This way, when the model synthesizes, it won't treat community rumors and official documentation as the same level of evidence.

Context Truncation

Search results can easily pollute the context. The tool output of DeepSearch should be concise and precise.

My suggestion is:

ContentInclude in tool output
TitleYes
URLYes
200-500 word snippetYes
Full page contentNo by default
Raw HTMLNo
Raw JSON from search APIIn details, not in the main content

If full text reading is indeed required, a second tool can be created:

fetch_source(url)

This way, DeepSearch's first step is to find candidate sources, and the second step only grabs the 2-3 most important pages. Don't immediately feed the model the full text of a dozen web pages.

Common Questions

Why not just use bash to run search scripts?

You can, but it's not as stable as an extension.

The problem with bash is that the model has to re-decide commands, parameters, output format, and error handling every time. An extension fixes these details, and the model only needs to call deep_search.

Why not write the summary in the extension as well?

Not recommended for the first version.

If the extension itself calls another model to summarize, you will encounter issues with nested model calls, cost accounting, context drift, and citation responsibility. A simpler approach is: the extension only returns evidence, and the model in the current Pi session is responsible for synthesis.

Is this DeepSearch considered MCP?

No. It's a local tool registered by a Pi extension.

If you already have a mature MCP search server, you can also integrate it via Pi's MCP-related packages or extensions. However, this example chooses to write the extension directly to understand Pi's own extension mechanism.

What security considerations should be kept in mind?

At least four things:

RiskPractice
API Key leakageRead only from environment variables, do not commit to repository
Untrusted web contentDo not treat web content as system instructions, only as evidence to be verified
Search result pollutionPrioritize official and source code, reduce weight of community results
Context explosionLimit result quantity and snippet length

DeepSearch appears to be "search enhancement," but it essentially brings external web pages into the agent's context. As long as external content enters the context, prompt injection should be treated as a real risk.

Summary

I would define the first practical example of Pi Agent as the DeepSearch Extension because it simultaneously demonstrates three key characteristics of Pi:

  • Pi's core is very small by default and does not include all workflows.
  • Truly useful capabilities can be added via extensions.
  • Extensions are not just about adding commands, but about defining the boundaries of the model's interaction with the external world.

Once this example is running, Pi will no longer be just a local code editing agent, but will have a controllable research entry point: when encountering questions requiring external information, it can first retrieve, then filter, and then answer with sources.

This is more reliable than letting the model answer from memory, and more reusable than manually writing search commands every time.

References

Pi Extensions 文档

官方扩展文档,介绍如何用 TypeScript 注册工具、命令、UI 和事件钩子。

PiPi Docs

Pi 使用文档

官方使用文档,覆盖交互模式、斜杠命令、会话、上下文文件、CLI 参数和设计原则。

PiPi Docs

Comments

Table of Contents

Pi Agent Practice Guide | Yu's Cyber Desk