07 · Custom Agent Development Guide

This chapter explains how to write, from scratch, a custom agent that the pi-web runner can load, covering the entry contract, tool definitions, model inheritance, the examples directory index, and development-time hot reload.

Learn by running: Every key concept in this chapter ships with a runnable example, scattered under examples/ in the repo. Recommended learning path (easy to hard): minimal-agenthello-agentbuiltin-tools-agentfile-session-agentserver-driven-ui-agentsystem-status-agentui-demo-agent. For a master index of what each example does and how to run it, see examples/README.md; the “Example Index (Learning Path)” section at the end of this chapter also has a quick-reference table.


Core Concepts

A pi-web agent is carried by a single TypeScript/JavaScript file (index.ts), whose default export must be one of the following three shapes:

ShapeDescription
(a) AgentDefinition objectThe most common; returned directly by defineAgent({...})
(b) (ctx: AgentContext) => AgentDefinition | Promise<AgentDefinition> factoryUse when you need to read the runtime environment
(c) CreateAgentSessionRuntimeFactory marked with RUNTIME_FACTORY_BRANDAdvanced usage; bypasses the normalization layer to build your own runtime

The runner bootstrap (packages/server/runner-bootstrap.mjs) loads index.ts via jiti, normalizes it through loadAgentDefinition (packages/server/src/runner/agent-loader.ts) into a unified runtime factory, then calls createAgentSessionRuntime to build the session, and finally enters runRpcMode to continuously handle RPC calls.


@blksails/pi-web-agent-kit

Package path: packages/agent-kit/src/index.ts

@blksails/pi-web-agent-kit is a lightweight helper package with zero hard runtime dependencies:

  • defineAgent(def) — an identity function used solely for compile-time type inference; it returns its input verbatim at runtime. An equivalent AgentDefinition object written without this package can still be loaded by the runner.
  • defineMinimalAgent(overrides?) — shallow-merges author overrides on top of minimalAgentPreset (noTools: "all" + empty skills + allowExtensions: []), yielding a zero-capability baseline in one line.
  • emitUi(onUpdate, spec) — emits a UiSpec from within a tool’s execute, triggering server-driven UI rendering (for the corresponding practice, see examples/server-driven-ui-agent: two trust paths — built-in allowlist components + sandboxed node trees; for the form combined with ambient status/notifications, see examples/system-status-agent).
  • Type exports: AgentDefinition, AgentContext, AgentModel, ToolDefinition, AttachmentToolContext, etc. (all pure types, with no value dependencies).
import { defineAgent } from "@blksails/pi-web-agent-kit";

AgentDefinition Field Reference

Source: packages/agent-kit/src/types.ts

FieldTypeDescription
modelAgentModel | undefinedOmitted → inherits defaultProvider/defaultModel from ~/.pi/agent/settings.json
thinkingLevelThinkingLevel | undefinedReasoning effort
systemPromptstring | (() => string) | undefinedSystem prompt; may be a lazy thunk
customToolsToolDefinition[]List of custom tools
toolsstring[]Allowlist of built-in/extension tool names
excludeToolsstring[]Tool exclusion list (applied after tools)
noTools"all" | "builtin""builtin" disables the built-in tool set (keeps custom/extension); "all" disables everything

Each of the three tool postures has a runnable example to compare against: noTools: "all" (zero-capability baseline) — see examples/minimal-agent; noTools: "builtin" (keep only custom tools) — see examples/hello-agent; using the tools allowlist to explicitly enable pi’s built-in filesystem/shell tool set — see examples/builtin-tools-agent. | extensions | Array<string \| ExtensionFactory> | Additional extensions to load (path or factory) | | allowExtensions | string[] \| undefined | Allowlist of system extensions; [] = disable all disk-discovered system extensions | | skills | SkillsOverride \| undefined | Override hook; receives the discovered skill set and returns a filtered set | | promptTemplates | PromptsOverride \| undefined | Override hook | | contextFiles | AgentsFilesOverride \| undefined | Overrides the AGENTS.md/CLAUDE.md discovery result | | scopedModels | Array<{model, thinkingLevel?}> | List of models switchable at runtime |


Complete Runnable Examples

Source: examples/hello-agent/index.ts (also the target agent for integration / e2e)

import { defineAgent } from "@blksails/pi-web-agent-kit";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "@earendil-works/pi-ai";
 
// Custom tool: echo
const echo = defineTool({
  name: "echo",
  label: "Echo",
  description: "Echo the provided text back to the caller.",
  parameters: Type.Object({
    text: Type.String({ description: "Text to echo back." }),
  }),
  async execute(_toolCallId, params) {
    return {
      content: [{ type: "text", text: params.text }],
      details: undefined,
    };
  },
});
 
export default defineAgent({
  // model omitted → inherits defaultProvider/defaultModel from ~/.pi/agent/settings.json
  systemPrompt: "You are hello-agent, a minimal pi-web example agent.",
  customTools: [echo],
  noTools: "builtin",          // disable the built-in tool set, keep only echo
  skills: ({ diagnostics }) => ({ skills: [], diagnostics }), // clear system skills
});

Key points:

  1. defineTool comes from @earendil-works/pi-coding-agent and Type from @earendil-works/pi-ai; the runner resolves both packages automatically via jiti alias, with no need to install dependencies in the agent directory.
  2. When the model field is omitted, the runner reads defaultProvider and defaultModel from ~/.pi/agent/settings.json, and resolves credentials from ~/.pi/agent/auth.json — working out of the box with any pi account.
  3. To pin a model, add model: { provider: "anthropic", modelId: "claude-opus-4-5" }, but the corresponding provider must have valid credentials.

Minimal Baseline (defineMinimalAgent)

Source: examples/minimal-agent/index.ts

import { defineMinimalAgent } from "@blksails/pi-web-agent-kit";
 
export default defineMinimalAgent({
  // model omitted → inherits configuration
  systemPrompt: "You are minimal-agent, a zero-capability pi-web baseline example.",
  // noTools: "all" + empty skills + allowExtensions: [] are provided by the preset, no need to redeclare
});

Factory Shape (shape b)

When an agent needs to read the runtime environment (such as cwd, env), use a factory function:

import { defineAgent } from "@blksails/pi-web-agent-kit";
import type { AgentContext } from "@blksails/pi-web-agent-kit";
 
export default async function (ctx: AgentContext) {
  const apiKey = ctx.env["MY_API_KEY"];
  return defineAgent({
    systemPrompt: `Working directory: ${ctx.cwd}`,
    customTools: apiKey ? [buildMyTool(apiKey)] : [],
  });
}

AgentContext provides:

  • ctx.cwd — the runner’s effective working directory
  • ctx.agentDir — the global agent config directory (typically ~/.pi/agent)
  • ctx.env — a snapshot of the process environment

examples/ Directory Index

Repo path: examples/ (for the master index and how to run each example, see examples/README.md)

SubdirectoryOne-line description
hello-agentMinimal complete example: custom echo tool + system prompt, with the built-in tool set disabled
minimal-agentZero-capability baseline: defineMinimalAgent preset, with noTools/skills/extensions all disabled
aigc-agentAssembles buildAigcTools() (image_generation / image_edit), demonstrating AIGC tools + the attachment seam
attachment-tool-agentDemonstrates the attachment-tool-bridge: a custom image tool persists its outputs to the attachment store via AttachmentToolContext
builtin-tools-agentEnables pi’s built-in tool set (the opposite posture to hello-agent’s noTools: "builtin")
file-session-agentA minimal agent paired with the file-store session demo (session storage is runtime configuration, not part of the AgentDefinition)
pi-probe-agentA probe agent used to verify that .pi/ project-level resources (extensions/skills) are correctly discovered and loaded
server-driven-ui-agentCalls emitUi(onUpdate, spec) within a tool’s execute to emit a UiSpec, with zero-config rendering on the frontend
system-status-agentCombines server-driven UI + ambient status/notifications; one tool demonstrates both paths at once
ui-demo-agentDemonstrates all of the extension UI interaction surfaces (ctx.ui.*: status push, ambient notifications, etc.)
webext-artifact-agentTier 4 artifact isolated-surface example: .pi/web declares the artifact entry, and the host renders it in a sandboxed iframe
webext-background-agentTier 1 background slot example: a .pi/web WebExtension renders an animated background layer (the background region)
webext-contrib-agentTier 3 contribution point example: slash / @mention, fetching candidates back from the agent via ui-rpc
webext-declarative-agentTier 5 pure-declarative example: .pi/web/manifest.json inlines theme tokens + layout, a zero-code UI extension
webext-layout-agentTier 1 region slot example: fills the panelRight and headerCenter regions
webext-renderer-agentTier 2 renderer example: registers a custom data-metric data-part renderer + echo tool
webext-slots-agentAcceptance fixture: declares 18 region slots (the protocol’s SlotKeySchema has 19 slots total; the fixture does not yet include logs), verifying that the host has wired up a SlotHost for each slot

Learning Path (easy to hard)

The table below strings the core concepts covered in “Custom Agent Development” above into a hands-on route from shallow to deep, each mapped to one runnable example. We recommend running them through in order:

OrderExampleConcepts you’ll learnCorresponding section in this chapter
1examples/minimal-agentdefineMinimalAgent preset / noTools: "all" zero-capability baselineCore Concepts, Minimal Baseline
2examples/hello-agentCustom defineTool + systemPrompt, noTools: "builtin" (e2e target)hello-agent Example
3examples/builtin-tools-agentUsing the tools allowlist to enable pi’s built-in filesystem/shell tool setnoTools / tools Fields
4examples/file-session-agentSession storage is runtime configuration, not part of the AgentDefinitionAgentDefinition Field Reference
5examples/server-driven-ui-agentemitUi(onUpdate, spec) emits data-pi-ui, with zero-config frontend renderingemitUi
6examples/system-status-agentserver-driven UI + ambient status/notifications combined (ctx.ui.setStatus/notify)emitUi
7examples/ui-demo-agentextension UI interaction surfaces: ctx.ui.select/confirm/input

The table above is the recommended order for the “Custom Agent Development” main line; aigc-agent, attachment-tool-agent, pi-probe-agent, and the webext-* series are specialized topics, covered respectively in 11 · AIGC Tools, 08 · Attachment System, 09 · Extensions & Skills, and 10 · Web UI Extension. For the full list and how to run each, see examples/README.md.


Development-Time Hot Reload

Background: the runner is a per-session resident subprocess that imports the agent entry only once, in-process, via jiti. After you modify packages/tool-kit/src, the runner for an existing session still runs the old code, requiring a new session to take effect.

How to enable:

# Enable hot reload in development mode
PI_RUNNER_HOT_RELOAD=1 pnpm dev

Or via the CLI’s --watch flag (works in any environment, not gated by NODE_ENV). Note that the two have different watch targets: PI_RUNNER_HOT_RELOAD=1 watches packages/tool-kit/src by default (good for editing tool source), whereas --watch <source> injects PI_WEB_WATCH=1 + PI_RUNNER_HOT_RELOAD_PATHS=<source> and watches the agent source directory you pass in (good for editing the agent’s own index.ts; a git source has no local directory, so watching is skipped):

pi-web --watch /path/to/my-agent

Mechanism (source: packages/server/src/rpc-channel/hot-reload.ts:24, bin/pi-web.mjs:138):

  1. isHotReloadEnabled() checks PI_WEB_WATCH=1 (injected by --watch) or NODE_ENV !== production && PI_RUNNER_HOT_RELOAD=1.
  2. Once enabled, registerForHotReload(target) watches the directory: by default packages/tool-kit/src, overridable via PI_RUNNER_HOT_RELOAD_PATHS (--watch uses exactly this to change the target to the agent source directory); debounced by 200 ms, responding only to .ts/.tsx/.js/.mjs/.cjs/.json changes.
  3. On a source change, requestRestart() is called on all registered PiRpcProcess instances, and the runner restarts the subprocess while idle (no pending commands).
  4. The new process re-reads the source with a fresh jiti instance; the session id is reused via spawnSpec, and the new runner resumes the conversation from the persisted jsonl, with no need for the user to restart the session.

Custom watch directories:

PI_RUNNER_HOT_RELOAD=1 \
PI_RUNNER_HOT_RELOAD_PATHS=/abs/path/to/my-tools,/abs/path/to/another-dir \
pnpm dev

PI_RUNNER_HOT_RELOAD_PATHS accepts a comma-separated list of absolute paths, overriding the default packages/tool-kit/src.


Bootstrap Flow

pi-web backend process
  └─ spawn node runner-bootstrap.mjs
       --agent <entry>  --cwd <work>  [--agent-dir <dir>]  [--session-id <id>]

         ├─ createJiti(here)              # jiti root anchored at the @blksails/pi-web-server package dir
         ├─ jiti.import("src/runner/runner.ts")
         └─ runner.ts: main(argv)
              ├─ parseRunnerArgs(argv)    # parse --agent / --cwd / --agent-dir etc.
              ├─ loadAgentDefinition(agent, ctx, trust)
              │    ├─ jiti.import(agentPath)  # load index.ts (shape a/b/c)
              │    └─ buildRuntimeFactory(def) # normalize into a unified runtime factory
              ├─ createAgentSessionRuntime(factory, {cwd, agentDir, sessionManager})
              ├─ wireAttachmentBridge(runtime)  # attachment-tool-bridge wiring
              └─ runRpcMode(runtime)       # enter the RPC loop, never returns

Key source files:

  • packages/server/runner-bootstrap.mjs — the launcher; pure ESM, needs no jiti to start itself
  • packages/server/src/runner/runner.tsmain() / startRunner() / parseRunnerArgs()
  • packages/server/src/runner/agent-loader.tsloadAgentDefinition(), normalizes the three shapes
  • packages/server/src/runner/option-mapper.tsbuildRuntimeFactory(), AgentDefinition → SDK calls

Development Steps

End-to-end from an empty directory to a working custom agent, as follows. Each step gives the expected result, for easy independent verification. If you’d rather start from a runnable minimal project directly, look first at examples/minimal-agent (zero-capability baseline) or examples/hello-agent (with one custom tool).

  1. Create the agent directory and add a new index.ts inside it:

    mkdir -p /path/to/my-agent
  2. Declare an AgentDefinition, providing at least a systemPrompt:

    // /path/to/my-agent/index.ts
    import { defineAgent } from "@blksails/pi-web-agent-kit";
    export default defineAgent({
      systemPrompt: "You are my custom agent.",
    });

    When model is omitted, it inherits the default provider/model from ~/.pi/agent/settings.json, with credentials resolved from ~/.pi/agent/auth.json — as long as pi is logged in on this machine, no extra configuration is needed.

  3. Start pi-web pointed at that directory; the simplest way is the CLI (PI_WEB_AUTOSTART=1 jumps straight into a session and skips the source-picker page):

    pi-web /path/to/my-agent

    Expected result: after the terminal prints the ready log, the browser opens automatically and enters the chat page; typing a sentence gets a model reply. You can also point at this directory manually from the source-picker page in the pi-web UI.

  4. Add a custom tool: use defineTool (@earendil-works/pi-coding-agent) + Type (@earendil-works/pi-ai), adding it to the customTools array (for the syntax, see the hello-agent example above). Verification: after reopening the session, ask the agent a question that requires the tool; the tool takes effect once its bubble appears.

  5. Adjust the tool switches:

    • noTools: "builtin" — disables built-in tools, keeping only customTools and .pi/extensions tools.
    • noTools: "all" — disables everything, equivalent to the tool posture of minimalAgentPreset.
    • Omit noTools — keeps the default built-in tool set.
  6. Enable hot reload (when editing tool-kit source): set PI_RUNNER_HOT_RELOAD=1; to edit the agent’s own index.ts, use pi-web --watch /path/to/my-agent. Changes automatically restart the runner while idle and resume the session, with no need to manually open a new session.

Common error remedies:

SymptomLikely causeRemedy
module has no default exportindex.ts has no export default, or exports only named exportsConfirm the default export is an AgentDefinition object / factory / branded factory
Model call 401 / auth failureThe provider specified by an explicit model has no valid credentialsDrop model to use the default, or provision auth for that provider; see 18 · Troubleshooting §2.1
Code changes don’t take effectThe runner is a resident subprocess and imports only onceEnable hot reload (see step 6) or manually open a new session

For more troubleshooting, see 18 · Troubleshooting FAQ.