20 KiB
Send-attachments tool implementation plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a send Pi custom tool that lets the agent push a file from the workspace back to the owner as a Telegram photo or document.
Architecture: New module src/tools/send.ts exports createSendTool({ bot, chatId, workspaceDir }) returning a Pi defineTool result. It validates the path stays inside workspaceDir, fs.stats it, auto-detects photo-vs-document by extension (with an explicit kind override), enforces Telegram's 10 MB photo / 50 MB document limits, then calls bot.sendPhoto or bot.sendDocument with a new InputFile(absPath). Wired into main.ts alongside schedule / calendar / Playwright, with a small paragraph appended to the system prompt so the model knows when to use it.
Tech Stack: TypeScript, Node 24, grammy (Telegram), @earendil-works/pi-coding-agent (defineTool), typebox (schema), vitest (tests).
Design spec: docs/superpowers/specs/2026-09-08-send-attachments-design.md
File Structure
- Create
src/tools/send.ts— schema,handleSend,createSendToolfactory. One file, all responsibility for the send tool. - Create
src/tools/send.test.ts— unit tests with a fakebot(twovi.fn()s), tmpdir workspaces, sparse files for size cases. - Modify
src/agent/system-prompt.ts— append a## Sending filesparagraph. - Modify
src/agent/system-prompt.test.ts— one new assertion the prompt mentions thesendtool. - Modify
src/main.ts— pushcreateSendTool({...})intocustomTools. - Modify
README.md— one line in the "Talking to it" section + one new manual E2E item.
Task 1: Create the send tool skeleton with schema and factory
Files:
-
Create:
src/tools/send.ts -
Step 1: Create the file with schema and factory stub
import { stat } from "node:fs/promises";
import path from "node:path";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { InputFile } from "grammy";
import { type Static, Type } from "typebox";
export interface SendBotApi {
sendPhoto(
chatId: number,
photo: InputFile,
other?: { caption?: string },
): Promise<unknown>;
sendDocument(
chatId: number,
document: InputFile,
other?: { caption?: string },
): Promise<unknown>;
}
export interface SendToolOptions {
bot: SendBotApi;
chatId: number;
workspaceDir: string;
}
export const sendParams = Type.Object({
path: Type.String({
description: "Workspace-relative path to the file to send.",
}),
kind: Type.Optional(
Type.Union(
[Type.Literal("auto"), Type.Literal("photo"), Type.Literal("document")],
{
description:
"auto (default): photo for image/jpeg|png|webp|gif, document otherwise. photo/document: force that kind.",
},
),
),
caption: Type.Optional(
Type.String({ description: "Optional caption shown under the attachment." }),
),
});
export type SendParams = Static<typeof sendParams>;
const PHOTO_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif"]);
const PHOTO_MAX_BYTES = 10 * 1024 * 1024;
const DOCUMENT_MAX_BYTES = 50 * 1024 * 1024;
export function createSendTool(opts: SendToolOptions) {
return defineTool({
name: "send",
label: "Send",
description:
"Send a file from the workspace to the owner as a Telegram attachment. Use for photos, screenshots, generated files, PDFs, or anything the owner should see or keep.",
promptSnippet: "Send a workspace file to the owner as a Telegram attachment",
parameters: sendParams,
execute: async (_toolCallId, params) => {
const text = await handleSend(params, opts);
return { content: [{ type: "text", text }], details: {} };
},
});
}
export async function handleSend(params: SendParams, opts: SendToolOptions): Promise<string> {
throw new Error("not implemented");
}
- Step 2: Type-check compiles
Run: npm run typecheck
Expected: PASS (no errors).
- Step 3: Commit
git add src/tools/send.ts
git commit -m "Add send tool skeleton with schema and factory"
Task 2: Reject paths that escape the workspace
Files:
-
Create:
src/tools/send.test.ts -
Modify:
src/tools/send.ts -
Step 1: Write the failing test
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InputFile } from "grammy";
import { createSendTool, handleSend, type SendBotApi } from "./send.js";
describe("send tool", () => {
let dir: string;
let bot: { sendPhoto: ReturnType<typeof vi.fn>; sendDocument: ReturnType<typeof vi.fn> };
let opts: { bot: SendBotApi; chatId: number; workspaceDir: string };
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "secretary-send-"));
bot = { sendPhoto: vi.fn().mockResolvedValue({}), sendDocument: vi.fn().mockResolvedValue({}) };
opts = { bot, chatId: 42, workspaceDir: dir };
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("rejects paths that escape the workspace", async () => {
await expect(handleSend({ path: "../etc/passwd" }, opts)).rejects.toThrow(/escapes workspace/);
await expect(handleSend({ path: "/etc/passwd" }, opts)).rejects.toThrow(/escapes workspace/);
expect(bot.sendPhoto).not.toHaveBeenCalled();
expect(bot.sendDocument).not.toHaveBeenCalled();
});
});
- Step 2: Run to verify it fails
Run: npx vitest run src/tools/send.test.ts
Expected: FAIL with "not implemented" (the current stub) or type/import issue if the file is empty.
- Step 3: Add the path-resolution guard to
handleSend
Replace the stub body of handleSend in src/tools/send.ts:
export async function handleSend(params: SendParams, opts: SendToolOptions): Promise<string> {
const abs = path.resolve(opts.workspaceDir, params.path);
const root = opts.workspaceDir.endsWith(path.sep) ? opts.workspaceDir : opts.workspaceDir + path.sep;
if (abs !== opts.workspaceDir && !abs.startsWith(root)) {
throw new Error(`path escapes workspace: ${params.path}`);
}
throw new Error("not implemented");
}
- Step 4: Run to verify it passes
Run: npx vitest run src/tools/send.test.ts
Expected: the "rejects paths that escape" test PASSES. Other unwritten tests don't exist yet.
- Step 5: Commit
git add src/tools/send.ts src/tools/send.test.ts
git commit -m "Reject send paths that escape the workspace"
Task 3: Reject missing files and directory paths
Files:
-
Modify:
src/tools/send.test.ts -
Modify:
src/tools/send.ts -
Step 1: Add failing tests
Append inside the describe("send tool", …) block in src/tools/send.test.ts:
it("throws when the file does not exist", async () => {
await expect(handleSend({ path: "nope.png" }, opts)).rejects.toThrow(/file not found: nope\.png/);
});
it("throws when the path is a directory", async () => {
const { mkdir } = await import("node:fs/promises");
await mkdir(path.join(dir, "sub"));
await expect(handleSend({ path: "sub" }, opts)).rejects.toThrow(/path is a directory, not a file: sub/);
});
- Step 2: Run to verify they fail
Run: npx vitest run src/tools/send.test.ts
Expected: two new tests FAIL with "not implemented".
- Step 3: Add stat + directory checks
Replace the tail of handleSend in src/tools/send.ts (the trailing throw new Error("not implemented");) with:
let s;
try {
s = await stat(abs);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`file not found: ${params.path}`);
}
throw err;
}
if (s.isDirectory()) throw new Error(`path is a directory, not a file: ${params.path}`);
throw new Error("not implemented");
- Step 4: Run to verify they pass
Run: npx vitest run src/tools/send.test.ts
Expected: three tests PASS (traversal, missing, directory). Anything else still throws "not implemented" and isn't asserted yet.
- Step 5: Commit
git add src/tools/send.ts src/tools/send.test.ts
git commit -m "Reject missing files and directory paths in send"
Task 4: Send a photo when the extension is an image
Files:
-
Modify:
src/tools/send.test.ts -
Modify:
src/tools/send.ts -
Step 1: Add failing test
Append inside the describe block:
it("sends a photo when kind is auto and the extension is an image", async () => {
await writeFile(path.join(dir, "hello.png"), Buffer.from("fake-png"));
const text = await handleSend({ path: "hello.png", caption: "hi" }, opts);
expect(text).toBe("Sent hello.png as photo.");
expect(bot.sendPhoto).toHaveBeenCalledTimes(1);
const [chatId, inputFile, other] = bot.sendPhoto.mock.calls[0];
expect(chatId).toBe(42);
expect(inputFile).toBeInstanceOf(InputFile);
expect(other).toEqual({ caption: "hi" });
expect(bot.sendDocument).not.toHaveBeenCalled();
});
- Step 2: Run to verify it fails
Run: npx vitest run src/tools/send.test.ts
Expected: this new test FAILs with "not implemented".
- Step 3: Implement the send branch
Replace the trailing throw new Error("not implemented"); in handleSend with:
const kind = resolveKind(params.kind, abs);
const other = params.caption !== undefined ? { caption: params.caption } : undefined;
const inputFile = new InputFile(abs);
if (kind === "photo") {
if (s.size > PHOTO_MAX_BYTES) {
throw new Error(
`photo too large: ${mb(s.size)} MB > 10 MB (Telegram limit). Use kind:"document" or downscale.`,
);
}
await opts.bot.sendPhoto(opts.chatId, inputFile, other);
} else {
if (s.size > DOCUMENT_MAX_BYTES) {
throw new Error(`document too large: ${mb(s.size)} MB > 50 MB (Telegram limit).`);
}
await opts.bot.sendDocument(opts.chatId, inputFile, other);
}
return `Sent ${params.path} as ${kind}.`;
}
function resolveKind(kind: SendParams["kind"], abs: string): "photo" | "document" {
if (kind === "photo") return "photo";
if (kind === "document") return "document";
return PHOTO_EXTS.has(path.extname(abs).toLowerCase()) ? "photo" : "document";
}
function mb(bytes: number): string {
return (bytes / (1024 * 1024)).toFixed(1);
}
- Step 4: Run to verify it passes
Run: npx vitest run src/tools/send.test.ts
Expected: four tests PASS.
- Step 5: Commit
git add src/tools/send.ts src/tools/send.test.ts
git commit -m "Send photos through sendPhoto with auto-detection"
Task 5: Route non-image extensions to sendDocument
Files:
-
Modify:
src/tools/send.test.ts -
Step 1: Add failing test
Append inside the describe block:
it("sends a document when kind is auto and the extension is not an image", async () => {
await writeFile(path.join(dir, "report.pdf"), Buffer.from("%PDF-1.4"));
const text = await handleSend({ path: "report.pdf" }, opts);
expect(text).toBe("Sent report.pdf as document.");
expect(bot.sendDocument).toHaveBeenCalledTimes(1);
const [chatId, inputFile, other] = bot.sendDocument.mock.calls[0];
expect(chatId).toBe(42);
expect(inputFile).toBeInstanceOf(InputFile);
expect(other).toBeUndefined();
expect(bot.sendPhoto).not.toHaveBeenCalled();
});
- Step 2: Run to verify it passes without further code changes
Run: npx vitest run src/tools/send.test.ts
Expected: PASS. resolveKind from Task 4 already handles this. If it fails, the branch above is wrong — fix it before continuing.
- Step 3: Commit
git add src/tools/send.test.ts
git commit -m "Test document routing for non-image extensions"
Task 6: Honor explicit kind overrides
Files:
-
Modify:
src/tools/send.test.ts -
Step 1: Add failing test
Append inside the describe block:
it("honors an explicit kind override", async () => {
await writeFile(path.join(dir, "hello.png"), Buffer.from("fake-png"));
await writeFile(path.join(dir, "report.pdf"), Buffer.from("%PDF-1.4"));
await handleSend({ path: "hello.png", kind: "document" }, opts);
expect(bot.sendDocument).toHaveBeenCalledTimes(1);
expect(bot.sendPhoto).not.toHaveBeenCalled();
await handleSend({ path: "report.pdf", kind: "photo" }, opts);
expect(bot.sendPhoto).toHaveBeenCalledTimes(1);
expect(bot.sendDocument).toHaveBeenCalledTimes(1);
});
- Step 2: Run to verify it passes without further code changes
Run: npx vitest run src/tools/send.test.ts
Expected: PASS. If it fails, resolveKind isn't respecting the explicit override — fix it.
- Step 3: Commit
git add src/tools/send.test.ts
git commit -m "Test explicit kind override for send"
Task 7: Reject oversized photos and documents
Files:
-
Modify:
src/tools/send.test.ts -
Step 1: Add failing tests
Append inside the describe block:
it("rejects photos over 10 MB", async () => {
const { open } = await import("node:fs/promises");
const fh = await open(path.join(dir, "big.png"), "w");
try {
await fh.truncate(11 * 1024 * 1024);
} finally {
await fh.close();
}
await expect(handleSend({ path: "big.png" }, opts)).rejects.toThrow(/photo too large: 11\.0 MB > 10 MB/);
expect(bot.sendPhoto).not.toHaveBeenCalled();
});
it("rejects documents over 50 MB", async () => {
const { open } = await import("node:fs/promises");
const fh = await open(path.join(dir, "big.bin"), "w");
try {
await fh.truncate(51 * 1024 * 1024);
} finally {
await fh.close();
}
await expect(handleSend({ path: "big.bin" }, opts)).rejects.toThrow(/document too large: 51\.0 MB > 50 MB/);
expect(bot.sendDocument).not.toHaveBeenCalled();
});
- Step 2: Run to verify they pass
Run: npx vitest run src/tools/send.test.ts
Expected: PASS. The size checks from Task 4 already cover this. If either fails, revisit the size branch in handleSend.
- Step 3: Commit
git add src/tools/send.test.ts
git commit -m "Test oversize photo/document rejection"
Task 8: Test the Pi tool wrapper end-to-end
Files:
-
Modify:
src/tools/send.test.ts -
Step 1: Add failing test
Append inside the describe block:
it("exposes a Pi tool that returns text content", async () => {
await writeFile(path.join(dir, "hello.png"), Buffer.from("fake-png"));
const tool = createSendTool(opts);
expect(tool.name).toBe("send");
const result = await tool.execute("call-1", { path: "hello.png" }, undefined, undefined, undefined as never);
expect(result.content).toEqual([{ type: "text", text: "Sent hello.png as photo." }]);
expect(bot.sendPhoto).toHaveBeenCalledTimes(1);
});
- Step 2: Run to verify it passes
Run: npx vitest run src/tools/send.test.ts
Expected: PASS. Nine tests total in the file.
- Step 3: Commit
git add src/tools/send.test.ts
git commit -m "Test Pi tool wrapper for send"
Task 9: Teach the system prompt about the send tool
Files:
-
Modify:
src/agent/system-prompt.ts -
Modify:
src/agent/system-prompt.test.ts -
Step 1: Add the failing assertion
Edit src/agent/system-prompt.test.ts, inside the existing describe("secretarySystemPrompt", …)'s only it(...) block, add one more assertion at the end:
expect(p).toContain("send");
expect(p).toContain("Sending files");
- Step 2: Run to verify it fails
Run: npx vitest run src/agent/system-prompt.test.ts
Expected: FAIL — the string "Sending files" isn't in the prompt yet.
- Step 3: Append the section to the prompt
Edit src/agent/system-prompt.ts. Extend the returned template literal in secretarySystemPrompt by appending, right before the closing backtick, this section (mind the leading \n\n — the existing template ends without a trailing newline):
- After creating a job, confirm it to the owner in one line including the next fire time.
## Sending files
- To send a file or image to the owner, use the \`send\` tool with a workspace-relative path.
- \`kind\` defaults to \`auto\` (photos for image extensions, documents otherwise); override with \`kind:"document"\` or \`kind:"photo"\` when needed.
- Prefer \`send\` over pasting long text when the owner asked for a file, a screenshot, or a rendered report.
- The attachment is separate from your final text reply — a one-line confirmation ("Sent, see above.") is enough.
Concretely: replace the current final bullet in the source file
- After creating a job, confirm it to the owner in one line including the next fire time.`;
with
- After creating a job, confirm it to the owner in one line including the next fire time.
## Sending files
- To send a file or image to the owner, use the \`send\` tool with a workspace-relative path.
- \`kind\` defaults to \`auto\` (photos for image extensions, documents otherwise); override with \`kind:"document"\` or \`kind:"photo"\` when needed.
- Prefer \`send\` over pasting long text when the owner asked for a file, a screenshot, or a rendered report.
- The attachment is separate from your final text reply — a one-line confirmation ("Sent, see above.") is enough.`;
- Step 4: Run to verify it passes
Run: npx vitest run src/agent/system-prompt.test.ts
Expected: PASS.
- Step 5: Commit
git add src/agent/system-prompt.ts src/agent/system-prompt.test.ts
git commit -m "Document the send tool in the system prompt"
Task 10: Wire the send tool into main.ts
Files:
-
Modify:
src/main.ts -
Step 1: Add the import
Edit src/main.ts. Under the existing import { createScheduleTool } from "./tools/schedule.js"; add:
import { createSendTool } from "./tools/send.js";
- Step 2: Register the tool alongside
schedule
Immediately after the line
const customTools: CustomTools = [createScheduleTool(scheduler, config.timezone)];
add:
customTools.push(createSendTool({
bot: bot.api,
chatId: config.telegramOwnerId,
workspaceDir: config.workspaceDir,
}));
- Step 3: Type-check compiles
Run: npm run typecheck
Expected: PASS. If bot.api doesn't satisfy SendBotApi (grammy's Api return types are broader than the interface), the compiler will point at the exact incompatibility — narrow SendBotApi return types to unknown (already done in Task 1) if it complains again.
- Step 4: Run the full test suite
Run: npm test
Expected: PASS. No behavioural regressions in existing tests; the new send tests are green.
- Step 5: Commit
git add src/main.ts
git commit -m "Wire send tool into main.ts"
Task 11: Document the tool in the README
Files:
-
Modify:
README.md -
Step 1: Add a "Sending files" line
Edit README.md. After the "Talking to it" section's second paragraph (the one ending "you get a 👀 reaction."), add a new paragraph:
It can also send files back. When you ask for a screenshot, a generated file, or a rendered report, the agent uses its `send` tool to attach the file directly — photos appear inline, other files arrive as documents.
- Step 2: Add a manual E2E item
In the "Manual end-to-end checklist" section, append a new numbered item after the current #17:
18. Ask "write a short poem to poem.txt and send it to me" → a `.txt` document attachment arrives with the poem inside, and the reply confirms with one line.
- Step 3: Commit
git add README.md
git commit -m "Document the send tool in the README"
Task 12: Final verification
Files: none (verification only).
- Step 1: Run the full test suite
Run: npm test
Expected: all tests PASS, including the ~9 new ones in src/tools/send.test.ts and the extended system-prompt assertion.
- Step 2: Build
Run: npm run build
Expected: PASS. dist/tools/send.js exists.
- Step 3: Sanity-check the git log
Run: git log --oneline -12
Expected: 11 commits from tasks 1–11, one per commit, in order, no stray "wip" / "fix" fixups.