Pi Coding Agent RPC Tutorial: JSON Protocol, Providers, and a Custom Web UI

Build a custom Pi Coding Agent RPC client with robust process management, JSON messages, event streaming, sessions, provider selection, Web UI isolation, security, and recovery.

Pi Coding Agent can not only be interacted with in the terminal. Its RPC mode runs the Agent as a child process, receives JSON commands through standard input, and continuously returns responses and events from standard output. This path is suitable for desktop shells, browser consoles, internal work order systems, or automated testing, but it is not as simple as fitting terminal output into a web page. What really needs to be dealt with is process lifetime, request numbers, streaming events, session directories, permissions, and exception recovery.

RPC mode does not solve remote network calls

RPC here is the native process protocol. The host program starts pi --mode rpc, writes a JSON object per line, and reads Pi’s standard output line by line. It doesn’t automatically listen on HTTP ports, and it doesn’t do user authentication, TLS, or cross-machine access for you. To provide a Web UI, the Pi child process should be held by its own backend, and the browser should only connect to this backend. Don’t let public web pages directly generate native commands or touch the working directory.

First confirm that the command and version are from the same installation

After installing the Pi, first check under the same account where you plan to run the service:

1
2
3
pi --version
pi --help
pi --mode rpc --help

Windows services, WSL, and plain PowerShell may each resolve to different executables. Confirm the path using the following command in PowerShell:

1
Get-Command pi | Select-Object Source, Version

To log versions before upgrading, RPC clients should be tested against an explicit version rather than defaulting to all event fields being changed forever.

Observe the protocol with minimal commands

Start in an empty test directory:

1
2
3
mkdir pi-rpc-lab
cd pi-rpc-lab
pi --mode rpc --no-session

After the process is started, the traditional interactive interface will not appear, but it will wait for the JSON line in the standard input. When sending a request, you must end it with a newline; just write JSON without a newline, and the parser may keep waiting. Manual experimentation is suitable to see the first response, formal integration must have the program read both stdout and stderr.

Treat stdout as protocol channel

Each line in stdout should first be parsed as a complete JSON message. Don’t write debugging hints, your own log prefixes, or color controls into the same pipe. The host program’s diagnostic logs are written to stderr or to a separate file. When receiving a line that cannot be parsed, record the original text, version, and message numbers before and after, but do not send the entire line that may contain the key to the public log.

A robust read cycle consists of four steps:

  1. Split the byte stream by newlines.
  2. Perform JSON parsing on a single line.
  3. Distribute responses or events based on the message type.
  4. Unknown types go into the compatibility branch instead of crashing the entire process.

Request ID is the core of concurrent correlation

The client may send control commands while the previous task is still streaming output. Therefore, the position assumption that “the next response belongs to the previous request” cannot be used. Generate a unique ID for each command and maintain the pending mapping. Unmapping occurs after the final response arrives; intermediate events are handled by the session or the current turn state.

1
2
3
4
5
6
7
type Pending = {
  resolve: (value: unknown) => void;
  reject: (reason: Error) => void;
  timer: ReturnType<typeof setTimeout>;
};

const pending = new Map<string, Pending>();

Timeout only means that the host did not get the final response on time, but it does not mean that the Pi child process has stopped. After the timeout, you should also decide whether to abort sending, continue receiving, or terminate the entire child process.

Node.js minimal skeleton for launching child processes

The following skeleton deliberately does not bind specific event fields, but is only responsible for reliable branching and process exit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { spawn } from "node:child_process";
import readline from "node:readline";

const child = spawn("pi", ["--mode", "rpc", "--no-session"], {
  cwd: process.cwd(),
  stdio: ["pipe", "pipe", "pipe"],
  shell: false,
  env: { ...process.env },
});

const lines = readline.createInterface({ input: child.stdout });

lines.on("line", (line) => {
  try {
    const message = JSON.parse(line);
    routeMessage(message);
  } catch (error) {
    console.error("invalid RPC JSON", { line, error });
  }
});

child.stderr.on("data", (chunk) => {
  process.stderr.write(`[pi] ${chunk}`);
});

child.on("exit", (code, signal) => {
  rejectAllPending(new Error(`pi exited: ${code}/${signal}`));
});

shell: false is important: parameters are passed in as arrays to avoid project paths or user text being interpreted again by the shell. If Windows cannot find pi, the absolute path should be resolved during the startup phase instead of concatenating the command string.

Unified encapsulation sending function

All writes go through an entry to limit message size, check process status, and guarantee trailing newlines.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function send(message: Record<string, unknown>) {
  if (!child.stdin.writable) {
    throw new Error("Pi RPC stdin is not writable");
  }

  const payload = JSON.stringify(message);
  if (Buffer.byteLength(payload, "utf8") > 256 * 1024) {
    throw new Error("RPC message is too large");
  }

  child.stdin.write(payload + "\n");
}

Do not embed JSON strings in large files. Put the file into a controlled working directory, let the agent read it through the tool, and check on the backend whether the real path is still in the directory.

Event flow requires explicit state machine

A prompt may go through queue, start, text increment, tool call, tool result, end, or error. Don’t just maintain a constantly appended string on the front end, otherwise tool events and retries can easily appear repeatedly. It is recommended to save the following status for each turn:

  • queued: The request has been written and has not yet been confirmed to start.
  • running: Model or tool events are being received.
  • aborting: The user request is aborted and waiting for the final status.
  • completed: The final response is complete.
  • failed: Protocol error, model error, or process exit.

When events arrive in an abnormal order, the original sequence numbers are retained, and the UI can prompt “Incomplete status” instead of pretending to be successful.

Deal with backpressure first, then talk about streaming experience

stdin.write() Returning false indicates that the buffer is under pressure. At this time, the transmission is suspended and continued, waiting for the drain event. The browser side must also limit the WebSocket pending queue, and slow clients cannot occupy server memory indefinitely. Text deltas can be merged and pushed every 30 to 80 milliseconds, reducing DOM updates and network packets. Tool results are typically sent as events in their entirety and are not suitable for character-level slicing.

Provider and model are fixed by startup parameters

The official RPC documentation provides --provider and --model startup parameters. For example, a service can start different workers for different purposes:

1
pi --mode rpc --provider anthropic --model MODEL_NAME --no-session

Don’t allow regular front-end users to submit arbitrary provider names or model parameters. The backend maintains the allow list and maps “Fast”, “High Quality” and “Local” to reviewed combinations. Verify credentials, context constraints, and tool capabilities before switching providers; models with the same name may also have different output or billing behavior.

The key only enters the child process environment

API keys are stored in server-side secret management tools or restricted environment variables. Do not write keys to RPC JSON, browser localStorage, session headers, or error postbacks. When starting a child process, you can construct a minimal environment instead of unconditionally inheriting all variables of the service process.

1
2
3
4
const childEnv = {
  PATH: process.env.PATH,
  ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
};

The actual variable name depends on the selected Provider; if it is missing, an error will be reported before starting to avoid the request failing halfway through.

Session directory determines recoverability

--no-session is suitable for one-time tasks, CI and protocol testing. It does not rely on historical sessions after the process exits. When you need to resume the session, use the Pi’s session capabilities and put the data in an explicit location via --session-dir.

1
pi --mode rpc --session-dir ./runtime/sessions

The server should map application user IDs to internal random directory names. Users are prohibited from directly inputting ../, drive letter or network share path. Before backing up a session, confirm whether it contains prompts, code snippets, tool output, or business data and set a retention period.

--name is used to distinguish controlled instances

Setting a distinguished name for long-running instances helps with log correlation:

1
pi --mode rpc --name support-agent --session-dir ./runtime/support

The name should be generated by the deployment configuration, do not directly use the email address, customer name, or the full text of the work order. The log also records the application instance ID, Pi process PID and startup version, so that you can restore which sub-process an exception belongs to.

Web UI should be separated by its own backend

The recommended link is:

1
2
3
4
5
6
Browser
  -> HTTPS / WebSocket
Application backend
  -> stdin/stdout JSON lines
Pi RPC child process
  -> model provider and local tools

The backend is responsible for login, rate limiting, working directory, provider whitelisting and auditing. The browser only receives the fields required for presentation and should not see native absolute paths, environment variables, or unmasked tool results. If the WebSocket is disconnected, the turn can be continued on the backend and the client can be replenished according to the event sequence number; it can also be actively terminated according to product rules.

One user, one process is not always correct

The resident child process recovers quickly, but takes up memory and saves more context. Each request creates a new process with clearer isolation, but increases startup and session recovery costs. A common compromise is to create workers based on workspaces, exit after being idle for a period of time, and put concurrent requests into each worker’s serial queue. Don’t let two requests modify the same Git workspace at the same time. When parallelism is required, create independent worktrees or temporary copies of tasks.

Tool permissions are more important than model selection

The Pi child process inherits the files and commands that the running account can access. When deploying the Web UI, perform at least the following isolation:

  • Use a non-administrator dedicated account.
  • The working directory uses an allow list.
  • Denies access to SSH keys, browser profiles, and production configurations.
  • Make a read-only or temporary copy of the external repository first.
  • Audit events are retained for writing files, executing commands, and network access.

Containers can reduce file system scope, but still need to limit mounts, networks, and host sockets. Hanging the Docker socket to the Agent is equivalent to granting high host control capabilities.

Suspension and shutdown must be separated

“Stop the current answer” is not the same as “kill the Pi process”. Prioritize using the abort command provided by the protocol so that the current turn receives a recognizable end status. The child process is terminated only if the protocol becomes unresponsive, stdout is closed, or the forced termination period is exceeded. When the service exits, it first stops receiving new requests, waits for a short grace period, then closes stdin and recycles the process. The signal semantics of Windows and Linux are different, and exit tests must be done separately.

Crash recovery cannot automatically replay write operations

When the Pi crashes after a tool call, the host does not necessarily know whether the file write has completed. Do not replay the last prompt unconditionally, otherwise you may re-submit, re-send the request, or overwrite the file. The recovery interface should display the last confirmation event and give the user the option to check the workspace, continue the conversation, or create a new session. Read-only queries can be designed with idempotent retries; write operations require operation IDs or post-execution validation.

Logs are divided into three categories: protocol, operation and audit

The protocol log records message type, request ID, event sequence number and time consumption, and does not save the complete text by default. Run logging records PID, version, exit code, memory and stderr summary. Audit logs record who opened which workspace, which tool capabilities were allowed, and what changes were made. Access permissions and retention periods are set for the three types of logs respectively. Masking rules cover at least API key, Authorization header, email, absolute user path and repository secret.

Write the protocol test first instead of manually clicking

The test client can start pi --mode rpc --no-session, send a fixed request, and verify:

  1. Each stdout line can be parsed as JSON.
  2. The request ID can be associated with the final response.
  3. Text and tool events are not settled twice.
  4. The pending promise will be cleared after timeout.
  5. All waiters receive failure when the child process exits abnormally.

Add input containing Chinese, backslash, newline, and overlong text to verify UTF-8 and JSON escaping. Don’t call expensive real models in CI; replace subprocesses with fake implementations that output fixed events according to the same protocol.

Do four fault drills before going online

First, disconnect the browser during operation and confirm that the backend does not cache events indefinitely. Second, let the Provider return current limiting or authentication failure to confirm that the error will not expose the key. Third, kill the Pi during tool execution to confirm that the system does not automatically replay writes. Fourth, let the unknown message type appear on stdout, confirm the client record and continue processing subsequent compatible messages. These results are better evidence that the integration is maintainable than “the page receives the first text.”

Choose RPC or use library directly

The advantages of RPC are language independence, clear process isolation, and the ability to reuse the official CLI startup configuration. The trade-off is maintaining subprocesses, JSON branches, state machines, and version compatibility. If the host itself is TypeScript and you need in-depth control of the Agent life cycle, you can evaluate directly integrating the corresponding library interface. If the host is Python, Go, a desktop program, or the Agent needs to be an independent worker, RPC is usually easier to draw boundaries.

What is the minimum deliverable version

A trustworthy first version should at least have: fixed Pi version, single-workspace serial queue, provider whitelist, backend authentication, event sequence number, timeout and abort, stderr log, session directory verification and exception exit cleanup. In the second stage, multi-workspace worker pools, disconnection resumption, tool approval and usage statistics will be added. Don’t make gorgeous chat bubbles first and then leave file permissions and crash recovery until after you go online.

Conclusion

The value of Pi Coding Agent RPC is to put the mature Agent running loop behind a clear process boundary. The quality of the integration depends on the host correctly managing JSON rows, request IDs, event statuses, session directories, and tool permissions. First use --no-session to complete the single request protocol test, then add persistent sessions and Web UI, and finally verify the recovery strategy through disconnection, current limiting and crash drills. What you get in this way is not a “terminal forwarder that can chat”, but a set of Agent services that can be audited, isolated and continuously upgraded.

Pi RPC document entry