The spec was confident. Pass the prompt as a JSON-stringified argument. Send it via cmux send. Done.
// What the spec assumed would work
piArgs.push(JSON.stringify(prompt));
cmux("send", "--surface", surfaceRef, piArgs.join(" ") + "\n");
That lasted exactly one test. The first prompt contained a quote. The second contained an exclamation mark. The third contained markdown code fences with backticks. Each one broke in a different, spectacular way.
By the end of this lesson, you'll understand why agent-to-agent communication through shell boundaries is a minefield, and why the final solution avoids the shell entirely. Files become the message bus.
Attempt 1: Inline JSON strings
The spec's first pass: stringify the prompt and append it as the final argument.
const prompt = "Fix the auth bug in the login form";
const piArgs = ["pi", "--model", model];
piArgs.push(JSON.stringify(prompt));
// Becomes: pi --model anthropic/claude-opus-4-6 "Fix the auth bug in the login form"
cmux("send", "--surface", surfaceRef, piArgs.join(" "));
cmux("send-key", "--surface", surfaceRef, "Enter");
This worked for simple strings. It broke the moment the prompt contained quotes:
const prompt = 'Review the "authentication" module';
piArgs.push(JSON.stringify(prompt));
// JSON.stringify() produces: "Review the \"authentication\" module"
// After shell parsing: Review the "authentication" module (quotes gone)
// Pi receives: Review the authentication module (broken)
The shell ate the escape characters. Pi got mangled input. First lesson: JSON.stringify() assumes a JSON parser, not a shell.
Attempt 2: Single-quote escaping
Replace JSON escaping with shell-safe single quotes.
const prompt = 'Review the "authentication" module';
const escapedPrompt = `'${prompt.replace(/'/g, `'"'"'`)}'`;
// Produces: 'Review the "authentication" module'
// Shell preserves everything inside single quotes
cmux("send", "--surface", surfaceRef, piArgs.join(" ") + " " + escapedPrompt);
The quote problem vanished. The shell preserved everything between single quotes exactly as written. This worked until someone tried to spawn a worker with this prompt:
Check the git history for recent changes! Look at the last 5 commits.
The terminal exploded:
zsh: event not found: Look
The exclamation mark triggered zsh's history expansion. !Look became zsh's attempt to find and execute a previous command starting with "Look". There wasn't one, so the command failed before pi ever saw it.
Attempt 3: Disable history expansion
Zsh history expansion can be turned off with set +H. Send that first, then the prompt.
cmux("send", "--surface", surfaceRef, "set +H");
cmux("send-key", "--surface", surfaceRef, "Enter");
// Now the original command should work
cmux("send", "--surface", surfaceRef, piArgs.join(" ") + " " + escapedPrompt);
cmux("send-key", "--surface", surfaceRef, "Enter");
The exclamation marks stopped exploding. History expansion was neutralized. This worked until someone tried a prompt with nested quotes and newlines:
Fix this code:
```javascript
const config = {
auth: "enabled",
users: ['admin', 'guest']
};
The error is in the 'users' array.
The single-quote escaping couldn't handle this. The prompt contained single quotes inside code fences. The escape logic (`'"'"'`) became incomprehensible nested quote soup. Even with history expansion disabled, complex prompts broke.
## Attempt 4: Bash-style escaping
Try bash's `$'...'` syntax for escape sequences.
```typescript
const bashEscaped = prompt
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\n/g, '\\n');
const escapedPrompt = `$'${bashEscaped}'`;
This handled newlines and quotes better, but introduced new problems. The escape logic was complex and fragile. Every special character needed its own escape rule. Backticks, dollars signs, parentheses — each one a new failure mode. The escaping code grew longer than the worker spawning code.
Attempt 5: Here documents
Try bash here-documents to send multi-line prompts.
const heredocDelimiter = `EOF_${Math.random().toString(36)}`;
const fullCmd = `pi --model ${model} << ${heredocDelimiter}
${prompt}
${heredocDelimiter}`;
cmux("send", "--surface", surfaceRef, fullCmd);
cmux("send-key", "--surface", surfaceRef, "Enter");
Here-docs solved the quoting problem but created new ones. The delimiter could collide with prompt content. Multi-line sends through cmux send were unreliable — sometimes the heredoc body was sent before the header finished. The complexity was spiraling.
The solution: temp files and @file syntax
Stop fighting the shell. Avoid it entirely.
// Write prompt to temp file to avoid shell escaping nightmares
const promptFile = path.join(cwd, `.pi-worker-${agentId}.md`);
writeFileSync(promptFile, params.prompt);
// Build the pi command with @file reference
const piArgs = ["pi", "--model", model];
if (params.skills?.length) {
for (const skill of params.skills) piArgs.push("--skill", skill);
}
piArgs.push(`@${promptFile}`);
// Send cd + pi as one chained command, clean up prompt file after
const fullCmd = `cd ${cwd} && PI_CMUX_ROLE=worker ${piArgs.join(" ")}; rm -f ${promptFile}`;
cmux("send", "--surface", surfaceRef, fullCmd);
cmux("send-key", "--surface", surfaceRef, "Enter");
Four lines replaced fifty lines of escaping logic. Here's why it works:
Step 1: Write to filesystem
writeFileSync(promptFile, params.prompt) writes the prompt exactly as received — no encoding, no escaping, no interpretation. The filesystem handles the complexity.
Step 2: Reference via @file
Pi's @file syntax reads file contents and uses them as the initial prompt. @.pi-worker-1a0g6g1w.md tells pi "read that file and treat its contents as my first message."
Step 3: Cleanup after pi exits
The command structure is cd <dir> && pi ... @<file>; rm -f <file>. The chained commands run in sequence. When pi exits (successfully or not), the temp file is removed. rm -f never fails — the -f flag silently ignores missing files.
Why this works everywhere:
- No shell interpretation — The prompt never passes through shell parsing. It goes straight from filesystem to pi.
- No special characters — Quotes, exclamation marks, backticks, newlines, code fences — none of it matters to the filesystem.
- No size limits — File-based communication handles kilobyte prompts as easily as single words.
- Race-condition free — One chained command means atomic execution. No interleaving of sends.
The principle
The broader lesson: when you need to pass structured data through a shell boundary, avoid the shell entirely. The shell is designed for interactive command composition, not data transport. It interprets everything — quotes, escape sequences, history expansion, variable substitution. That interpretation is useful for humans typing commands. It's disastrous for agents sending arbitrary text.
Use files as the message bus. Write data to temporary files, pass filenames through the shell, read on the other side. The filesystem doesn't interpret content. It just stores and retrieves bytes.
This pattern applies beyond prompt passing:
- Configuration — Don't pass JSON via environment variables or command args. Write JSON files, pass paths.
- Inter-agent communication — Don't embed messages in command lines. Write message files, signal via filesystem events.
- Large payloads — Don't inline large data structures. Write to temp storage, pass references.
The shell is a bridge, not a transport layer. Keep the bridge simple — just paths and simple flags. The data lives in files.
Test the final implementation
Try spawning a worker with a complex prompt:
Create a React component that displays user profiles. The component should:
1. Accept a `users` prop with this shape:
```typescript
interface User {
id: string;
name: string;
email: string;
role: "admin" | "user";
}
- Handle the edge case where
usersis empty - Use the design system's
Cardcomponent - Show proper loading states
The implementation should follow React best practices. Don't forget error boundaries!
That prompt contains:
- Nested quotes in TypeScript interfaces
- Backticks in markdown code fences
- Exclamation marks in instructions
- Newlines and indentation
- Pipe characters in union types
Before the temp file solution, this would have broken five different ways. Now it works perfectly. Pi receives the prompt exactly as written. The worker starts immediately. The temp file vanishes after pi boots.
That's the difference between fighting the shell and avoiding it. Files don't argue with quotes. They just store what you give them.