Skip to content

Instantly share code, notes, and snippets.

@aliou
Last active July 13, 2026 13:09
Show Gist options
  • Select an option

  • Save aliou/4593c78bb5a9a68c829a863caa8869cc to your computer and use it in GitHub Desktop.

Select an option

Save aliou/4593c78bb5a9a68c829a863caa8869cc to your computer and use it in GitHub Desktop.
Pi RPC extension UI file-transfer demo

Pi RPC client-to-tool file transfer

This gist contains both the supported current-Pi approach and a custom raw-process approach.

Example Status Files
Supported dialog bridge Works with current Pi client.mjs, server.ts
Custom raw-process bridge Works with Pi versions before 0.62.0 custom-client.mjs, custom-server.ts

Both examples keep the file on the client device until the client chooses to send it. The Pi extension receives a response and returns a normal tool result.

Requirements

  • Node.js 20 or later
  • Pi 0.80.6 or later, authenticated with a tool-capable model
  • A regular local file

The examples do not impose a file-size limit. The practical limit is set by available memory, process I/O, transport limits, and any model context included by the server-side handler. Base64 adds size overhead. Production code should set its own size, type, timeout, and consent policy.

Supported dialog bridge

client.mjs starts Pi in RPC mode and selects the local file. server.ts is a Pi extension loaded into that process. When the model calls receive_file_from_client, the extension uses the supported editor dialog. The client replies with a JSON envelope containing the filename, MIME type, and base64-encoded bytes. The extension validates the bytes, computes a SHA-256 digest, and returns a tool result.

+-------------------+                         +--------------------------+
| RPC client/device |                         | Pi process + server.ts   |
| reads local file  |                         | extension tool           |
+---------+---------+                         +------------+-------------+
          |                                                |
          | prompt                                         |
          +----------------------------------------------->|
          |                                                | model calls tool
          | extension_ui_request: method=editor            |
          |<-----------------------------------------------+
          |                                                |
          | extension_ui_response: value=<base64 envelope> |
          +----------------------------------------------->|
          |                                                | decode, hash
          | tool_execution_end                             |
          |<-----------------------------------------------+

Run it:

node client.mjs /path/to/file.pdf

Pass normal Pi options after the file path when needed:

node client.mjs ./notes.txt --provider anthropic --model claude-sonnet-4-20250514

Set PI_BIN when pi is not on PATH:

PI_BIN=/path/to/pi node client.mjs ./notes.txt

Expected output includes both sides of the transfer:

[client] Sending notes.txt (42 bytes) to the server extension.
[pi] [receive-file] received notes.txt: 42 bytes, sha256=...

[server tool result]
Received notes.txt from the RPC client.
MIME type: text/plain
Bytes: 42
SHA-256: ...

Custom raw-process bridge

A custom native_tool_call method can write its own extension_ui_request JSON line and listen directly to process.stdin; the client recognizes that method, runs a native tool, and replies with a result envelope.

+-------------------+                         +-----------------------------------+
| RPC client/device |                         | Pi process + custom-server.ts     |
| local tool runner |                         | custom stdout/stdin bypass         |
+---------+---------+                         +----------------+------------------+
          |                                                    |
          | extension_ui_request: method=native_tool_call      |
          |<---------------------------------------------------+
          |                                                    |
          | run tool on device                                 |
          |                                                    |
          | extension_ui_response: { ok, result }              |
          +--------------------------------------------------->|
          |                                                    | resolve pending tool call

The JavaScript client handles the custom request and sends its file envelope back as a normal response:

if (event.type === "extension_ui_request" && event.method === "native_tool_call") {
  const bytes = readFileSync(filePath);
  send({
    type: "extension_ui_response",
    id: event.id,
    value: {
      ok: true,
      result: {
        name: basename(filePath),
        mimeType: mimeTypeFor(filePath),
        data: bytes.toString("base64"),
      },
    },
  });
}

Run this bridge with a compatible Pi binary:

PI_BIN=/path/to/pi-0.61-or-earlier node custom-client.mjs /path/to/file.pdf

Pi 0.62.0 and later reserve stdout for RPC output. In current Pi source, runRpcMode() calls takeOverStdout() before extensions load; takeOverStdout() redirects process.stdout.write() to stderr. That prevents custom-server.ts from putting its request on the RPC stdout stream. The supported dialog bridge above is the current-Pi approach.

Protocol boundary

Current Pi RPC mode accepts only its documented extension UI methods. ctx.ui.custom() is TUI-only and emits nothing in RPC mode. The supported example uses the editor dialog:

  1. server.ts calls ctx.ui.editor(...) inside the registered tool.
  2. Pi emits extension_ui_request with method: "editor".
  3. client.mjs replies with extension_ui_response containing a string value.
  4. The extension decodes that string and returns a normal Pi tool result.

The client is intentionally the only process that reads the local file. The Pi process receives only the response payload. For PDF, audio, or other binary data, replace the SHA-256 handling in server.ts with the server-side processor you need. Do not send sensitive files without explicit user consent.

#!/usr/bin/env node
/**
* Starts Pi in RPC mode, supplies one local file when server.ts asks for it,
* and prints the server-side tool result.
*/
import { spawn } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import { basename, dirname, extname, resolve } from "node:path";
import { StringDecoder } from "node:string_decoder";
import { fileURLToPath } from "node:url";
const UPLOAD_TITLE = "Upload local file to receive_file_from_client";
const demoDir = dirname(fileURLToPath(import.meta.url));
const [fileArgument, ...extraPiArgs] = process.argv.slice(2);
if (!fileArgument) {
console.error("Usage: node client.mjs <file> [additional pi arguments]");
process.exitCode = 1;
} else {
run(resolve(fileArgument));
}
function mimeTypeFor(filePath) {
switch (extname(filePath).toLowerCase()) {
case ".pdf":
return "application/pdf";
case ".json":
return "application/json";
case ".md":
return "text/markdown";
case ".txt":
return "text/plain";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".mp3":
return "audio/mpeg";
case ".wav":
return "audio/wav";
default:
return "application/octet-stream";
}
}
function run(filePath) {
let stat;
try {
stat = statSync(filePath);
} catch (error) {
console.error(`Cannot read ${filePath}: ${error.message}`);
process.exitCode = 1;
return;
}
if (!stat.isFile()) {
console.error(`${filePath} is not a regular file.`);
process.exitCode = 1;
return;
}
const file = {
name: basename(filePath),
mimeType: mimeTypeFor(filePath),
data: readFileSync(filePath).toString("base64"),
};
const pi = spawn(
process.env.PI_BIN ?? "pi",
[
"--mode",
"rpc",
"--no-session",
"--no-context-files",
"--no-extensions",
"--no-builtin-tools",
"-e",
resolve(demoDir, "server.ts"),
...extraPiArgs,
],
{ cwd: demoDir, stdio: ["pipe", "pipe", "pipe"] },
);
let sawUploadRequest = false;
let settled = false;
function send(message) {
pi.stdin.write(`${JSON.stringify(message)}\n`);
}
attachJsonlReader(pi.stdout, (line) => {
let event;
try {
event = JSON.parse(line);
} catch {
console.error(`[client] Ignoring invalid JSON from Pi: ${line}`);
return;
}
if (event.type === "extension_ui_request" && event.method === "editor" && event.title === UPLOAD_TITLE) {
sawUploadRequest = true;
console.error(`[client] Sending ${file.name} (${stat.size} bytes) to the server extension.`);
send({ type: "extension_ui_response", id: event.id, value: JSON.stringify(file) });
return;
}
if (event.type === "tool_execution_end" && event.toolName === "receive_file_from_client") {
const text = event.result?.content
?.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n");
console.log(`\n[server tool result]\n${text ?? JSON.stringify(event.result)}`);
return;
}
if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
return;
}
if (event.type === "agent_settled" && !settled) {
settled = true;
pi.stdin.end();
}
});
pi.stderr.on("data", (chunk) => process.stderr.write(`[pi] ${chunk}`));
pi.on("error", (error) => {
console.error(`Could not start Pi: ${error.message}`);
process.exitCode = 1;
});
pi.on("close", (code, signal) => {
if (!sawUploadRequest) {
console.error("[client] Pi settled without requesting the file. Check model credentials and tool availability.");
process.exitCode = 1;
}
if (code !== 0 && signal === null) process.exitCode = code;
});
send({
id: "upload-demo",
type: "prompt",
message:
"Call the receive_file_from_client tool exactly once. After it returns, report only the received filename, byte count, and SHA-256.",
});
}
function attachJsonlReader(stream, onLine) {
const decoder = new StringDecoder("utf8");
let buffer = "";
stream.on("data", (chunk) => {
buffer += decoder.write(chunk);
while (true) {
const newline = buffer.indexOf("\n");
if (newline === -1) return;
let line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.endsWith("\r")) line = line.slice(0, -1);
if (line) onLine(line);
}
});
stream.on("end", () => {
buffer += decoder.end();
if (buffer) onLine(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer);
});
}
#!/usr/bin/env node
/**
* Companion to custom-server.ts.
*
* This demonstrates a custom `native_tool_call` client handler. It
* requires direct stdout access and will time out on current Pi.
*/
import { spawn } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import { basename, dirname, extname, resolve } from "node:path";
import { StringDecoder } from "node:string_decoder";
import { fileURLToPath } from "node:url";
const demoDir = dirname(fileURLToPath(import.meta.url));
const [fileArgument, ...extraPiArgs] = process.argv.slice(2);
if (!fileArgument) {
console.error("Usage: node custom-client.mjs <file> [additional pi arguments]");
process.exitCode = 1;
} else {
run(resolve(fileArgument));
}
function mimeTypeFor(filePath) {
switch (extname(filePath).toLowerCase()) {
case ".pdf":
return "application/pdf";
case ".json":
return "application/json";
case ".md":
return "text/markdown";
case ".txt":
return "text/plain";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".mp3":
return "audio/mpeg";
case ".wav":
return "audio/wav";
default:
return "application/octet-stream";
}
}
function run(filePath) {
let stat;
try {
stat = statSync(filePath);
} catch (error) {
console.error(`Cannot read ${filePath}: ${error.message}`);
process.exitCode = 1;
return;
}
if (!stat.isFile()) {
console.error("Select a regular file.");
process.exitCode = 1;
return;
}
const bytes = readFileSync(filePath);
const result = {
ok: true,
result: {
name: basename(filePath),
mimeType: mimeTypeFor(filePath),
data: bytes.toString("base64"),
},
};
const pi = spawn(
process.env.PI_BIN ?? "pi",
[
"--mode",
"rpc",
"--no-session",
"--no-context-files",
"--no-extensions",
"--no-builtin-tools",
"-e",
resolve(demoDir, "custom-server.ts"),
...extraPiArgs,
],
{ cwd: demoDir, stdio: ["pipe", "pipe", "pipe"] },
);
let receivedRequest = false;
function send(message) {
pi.stdin.write(`${JSON.stringify(message)}\n`);
}
attachJsonlReader(pi.stdout, (line) => {
const event = JSON.parse(line);
if (event.type === "extension_ui_request" && event.method === "native_tool_call") {
receivedRequest = true;
console.error(`[custom client] Executing ${event.toolName} on this device.`);
send({ type: "extension_ui_response", id: event.id, value: result });
}
if (event.type === "tool_execution_end" && event.toolName === "receive_file_from_client") {
console.log(`\n[custom server tool result]\n${JSON.stringify(event.result, null, 2)}`);
}
if (event.type === "agent_settled") pi.stdin.end();
});
pi.stderr.on("data", (chunk) => process.stderr.write(`[pi] ${chunk}`));
pi.on("close", () => {
if (!receivedRequest) {
console.error("[custom client] No custom request arrived. This is expected on current Pi.");
process.exitCode = 1;
}
});
send({
type: "prompt",
message: "Call receive_file_from_client exactly once, then report the result.",
});
}
function attachJsonlReader(stream, onLine) {
const decoder = new StringDecoder("utf8");
let buffer = "";
stream.on("data", (chunk) => {
buffer += decoder.write(chunk);
while (true) {
const newline = buffer.indexOf("\n");
if (newline === -1) return;
let line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.endsWith("\r")) line = line.slice(0, -1);
if (line) onLine(line);
}
});
}
/**
* Custom bridge, preserved as a protocol example.
*
* This intentionally bypasses ctx.ui and writes a custom method to stdout.
* It requires a Pi runtime that exposes process.stdout directly. Current Pi
* redirects process.stdout.write to stderr in RPC mode, so use server.ts for
* a supported current-Pi implementation.
*/
import { createHash, randomUUID } from "node:crypto";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
const pending = new Map<string, PendingRequest>();
interface FileEnvelope {
name: string;
mimeType: string;
data: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function parseToolResult(value: unknown): { file: FileEnvelope; bytes: Buffer } {
if (!isRecord(value) || value.ok !== true || !isRecord(value.result)) {
throw new Error("Client returned an invalid native tool result.");
}
const { name, mimeType, data } = value.result;
if (typeof name !== "string" || typeof mimeType !== "string" || typeof data !== "string") {
throw new Error("Client did not return a file envelope.");
}
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 !== 0) {
throw new Error("Client did not return canonical base64 data.");
}
const bytes = Buffer.from(data, "base64");
if (bytes.toString("base64") !== data) {
throw new Error("Client did not return canonical base64 data.");
}
return { file: { name, mimeType, data }, bytes };
}
function writeCustomRequest(request: Record<string, unknown>): void {
// Custom raw-process bypass: current Pi sends this to stderr, not RPC stdout.
process.stdout.write(`${JSON.stringify(request)}\n`);
}
function installResponseListener(): void {
let buffer = "";
process.stdin.on("data", (chunk: Buffer) => {
buffer += chunk.toString("utf8");
while (true) {
const newline = buffer.indexOf("\n");
if (newline === -1) return;
const line = buffer.slice(0, newline).replace(/\r$/, "");
buffer = buffer.slice(newline + 1);
try {
const response: unknown = JSON.parse(line);
if (
typeof response !== "object" ||
response === null ||
!("type" in response) ||
response.type !== "extension_ui_response" ||
!("id" in response) ||
typeof response.id !== "string"
) {
continue;
}
const request = pending.get(response.id);
if (!request) continue;
pending.delete(response.id);
clearTimeout(request.timeout);
request.resolve("value" in response ? response.value : undefined);
} catch {
// Ignore malformed or unrelated RPC input.
}
}
});
}
function requestNativeTool(toolName: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const id = randomUUID();
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error("Timed out waiting for the device client."));
}, 30_000);
pending.set(id, { resolve, reject, timeout });
writeCustomRequest({
type: "extension_ui_request",
id,
method: "native_tool_call",
toolName,
args: {},
});
});
}
export default function (pi: ExtensionAPI): void {
installResponseListener();
pi.registerTool({
name: "receive_file_from_client",
label: "Receive File From Client",
description: "Ask the device client to send its selected local file.",
parameters: Type.Object({}),
async execute() {
try {
const result = await requestNativeTool("receive_file_from_client");
const { file, bytes } = parseToolResult(result);
const sha256 = createHash("sha256").update(bytes).digest("hex");
console.error(`[custom-server] received ${file.name}: ${bytes.length} bytes, sha256=${sha256}`);
return {
content: [
{
type: "text",
text: [
`Received ${file.name} from the RPC client.`,
`MIME type: ${file.mimeType}`,
`Bytes: ${bytes.length}`,
`SHA-256: ${sha256}`,
].join("\n"),
},
],
details: { received: true, name: file.name, mimeType: file.mimeType, bytes: bytes.length, sha256 },
};
} catch (error) {
const message = error instanceof Error ? error.message : "Native tool request failed.";
return {
content: [{ type: "text", text: message }],
details: { error: message },
isError: true,
};
}
},
});
}
/**
* Pi extension loaded by client.mjs into `pi --mode rpc`.
*
* The tool asks the RPC client for one value through the supported `editor`
* extension UI method. The client responds with a JSON envelope containing a
* base64-encoded local file. This extension validates and handles the bytes.
*/
import { createHash } from "node:crypto";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
const UPLOAD_TITLE = "Upload local file to receive_file_from_client";
interface FileEnvelope {
name: string;
mimeType: string;
data: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function parseFileEnvelope(value: string): { envelope: FileEnvelope; bytes: Buffer } {
const parsed: unknown = JSON.parse(value);
if (
!isRecord(parsed) ||
typeof parsed.name !== "string" ||
typeof parsed.mimeType !== "string" ||
typeof parsed.data !== "string"
) {
throw new Error("Client response is not a file envelope.");
}
const { name, mimeType, data } = parsed;
if (!name || name.length > 255 || !mimeType || mimeType.length > 255) {
throw new Error("Client response has invalid file metadata.");
}
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 !== 0) {
throw new Error("Client response does not contain canonical base64 data.");
}
const bytes = Buffer.from(data, "base64");
if (bytes.toString("base64") !== data) {
throw new Error("Client response does not contain canonical base64 data.");
}
return { envelope: { name, mimeType, data }, bytes };
}
function textPreview(bytes: Buffer): string | undefined {
const text = bytes.toString("utf8");
if (!Buffer.from(text, "utf8").equals(bytes)) return undefined;
const printable = [...text].filter((char) => {
const code = char.codePointAt(0) ?? 0;
return code >= 32 || char === "\n" || char === "\r" || char === "\t";
});
if (printable.length / Math.max(text.length, 1) < 0.95) return undefined;
return text;
}
export default function (pi: ExtensionAPI): void {
pi.registerTool({
name: "receive_file_from_client",
label: "Receive File From Client",
description:
"Receive the one local file selected by the RPC client. Use this tool exactly once when the user asks to inspect that uploaded file.",
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const response = await ctx.ui.editor(UPLOAD_TITLE);
if (response === undefined) {
return {
content: [{ type: "text", text: "The client cancelled the file upload." }],
details: { received: false },
};
}
try {
const { envelope, bytes } = parseFileEnvelope(response);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const preview = textPreview(bytes);
// stderr is safe for extension diagnostics; stdout belongs to Pi's RPC protocol.
console.error(
`[receive-file] received ${envelope.name}: ${bytes.length} bytes, sha256=${sha256}`,
);
const lines = [
`Received ${envelope.name} from the RPC client.`,
`MIME type: ${envelope.mimeType}`,
`Bytes: ${bytes.length}`,
`SHA-256: ${sha256}`,
];
if (preview !== undefined) {
lines.push(`UTF-8 content:\n${preview}`);
} else {
lines.push("Binary file received. The tool processed its raw bytes without adding them to model context.");
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
received: true,
name: envelope.name,
mimeType: envelope.mimeType,
bytes: bytes.length,
sha256,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid file response.";
return {
content: [{ type: "text", text: `File upload failed: ${message}` }],
details: { received: false, error: message },
isError: true,
};
}
},
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment