Fake agent
Hooks, timeline cards and injected MCP tools need agent turns to test, and real providers need credentials, cost money and never do the same thing twice. The fake agent is a provider plugin that runs one scripted turn per prompt, with the timeline items real adapters produce.
mkdir -p /tmp/ws
npx ppd daemon up --fake-agent
npx ppd paseo -- run --provider fake-agent --cwd /tmp/ws '{"steps":[{"write":{"path":"notes.md","content":"hi"}},{"say":"done"}]}' --fake-agent installs the provider (assets/fake-agent)
into the dev daemon, on start or into a daemon that is already running:
fake agent provider "fake-agent" installed: paseo run --provider fake-agent '<script JSON>' It appears in the app as the provider fake-agent with one model, "Scripted". It writes and edits files for real in
the agent's cwd and calls tools on the MCP servers in the agent's session config, which is where plugins inject them
in before("agent.create"). Paseo's own mock provider needs a development build and supports no MCP servers.
paseo run --cwd DIR needs DIR to exist and creates a workspace for it, so leave --workspace off daemon up here.
With two workspaces, ppd web needs --workspace to know which one to open; pass --workspace <id> to paseo run to reuse one.
What counts as a script
For every prompt, in order:
- The prompt contains a fenced block whose language is
fake-agent. Its body is the script, so the script can sit inside a longer prompt. - The whole prompt is JSON (it starts with a brace or a bracket): an object with a
stepsarray, or a bare array of steps. - The agent's cwd has a
.fake-agent.json: turn N runs its Nth script (see several turns). - Otherwise the agent answers with one message:
fake-agent received: <prompt>.
A script from the prompt must parse; an invalid one fails the turn with fake-agent: invalid script: ....
Steps
Each step has exactly one key. Steps run in order.
| Step | Effect | Timeline item |
|---|---|---|
{"say": "text"} | assistant_message | |
{"think": "text"} | reasoning | |
{"write": {"path", "content"}} | Writes the file (relative to the cwd), creating directories. | tool_call Write, detail: {type: "write", filePath, content} with an absolute filePath |
{"edit": {"path", "old", "new"}} | Replaces the first old with new. | tool_call Edit, detail: {type: "edit", filePath, oldString, newString}; failed when the file or the string is missing |
{"bash": {"command", "output"?, "exitCode"?, "run"?}} | Without run, records the command with the output and exit code you give. With run: true, runs it (see shell steps). | tool_call Bash, detail: {type: "shell", command, cwd, output, exitCode}; failed when the exit code is not 0 |
{"mcp": {"server", "tool", "arguments"?}} | Calls the tool on that MCP server (see MCP steps). | tool_call mcp__<server>__<tool>, detail: {type: "unknown", input, output}; failed on errors |
{"sleep": ms} | Waits. An interrupt cancels the turn. | |
{"fail": "message"} | Ends the turn as failed. | error |
"stepDelayMs": N next to steps pauses N ms before every step, which makes a turn slow enough to watch in the UI.
A turn, end to end
npx ppd paseo -- run --provider fake-agent --cwd /tmp/ws '{"steps":[
{"think":"add a constant, then check it"},
{"write":{"path":"src/a.ts","content":"export const a = 1;\n"}},
{"edit":{"path":"src/a.ts","old":"1","new":"2"}},
{"bash":{"command":"cat src/a.ts","run":true}},
{"bash":{"command":"npm test","output":"1 failed","exitCode":1}},
{"say":"a is 2 now"}]}'
npx ppd paseo -- logs <agent id> [User] {"steps":[{"think":"add a constant, then check it"},{"write":...},...]}
[Thought] add a constant, then check it
[Write] /tmp/ws/src/a.ts
[Edit] /tmp/ws/src/a.ts
[Shell] cat src/a.ts
[Shell] npm test
a is 2 now src/a.ts now holds export const a = 2;. The canonical timeline, as a plugin's agent.turn_ended hook sees it
(read with agentTimeline, below):
{"type":"reasoning","text":"add a constant, then check it"}
{"type":"tool_call","callId":"t-1-1","name":"Write","detail":{"type":"write","filePath":"/tmp/ws/src/a.ts","content":"export const a = 1;\n"},"status":"completed","error":null}
{"type":"tool_call","callId":"t-1-2","name":"Edit","detail":{"type":"edit","filePath":"/tmp/ws/src/a.ts","oldString":"1","newString":"2"},"status":"completed","error":null}
{"type":"tool_call","callId":"t-1-3","name":"Bash","detail":{"type":"shell","command":"cat src/a.ts","cwd":"/tmp/ws","output":"export const a = 2;\n","exitCode":0},"status":"completed","error":null}
{"type":"tool_call","callId":"t-1-4","name":"Bash","detail":{"type":"shell","command":"npm test","cwd":"/tmp/ws","output":"1 failed","exitCode":1},"status":"failed","error":"exit code 1"}
{"type":"assistant_message","text":"a is 2 now","messageId":"a-1-5"} A failed tool call does not fail the turn; this one ended completed.
How a turn ends
A turn emits user_message, then its steps, then ends like a real provider's, so agent.turn_started and agent.turn_ended fire as usual:
completed: every step ran.failed: afailstep, an invalid script, or a shell step the daemon refused.paseo runshows the agent aserror, and Paseo adds a system error to the timeline:starting [Error] tests failed [System Error] tests failedcanceled: the agent was interrupted, for example withnpx ppd paseo -- stop <agent id>. Asleepends at once, a running command is killed (its tool call fails withcanceled), an MCP call in flight finishes first, and the remaining steps do not run. The agent goes back toidle.
Several turns
A plain prompt in a cwd that has a .fake-agent.json runs the next scripted turn. The last one repeats:
{
"turns": [
{ "steps": [{ "write": { "path": "todo.md", "content": "- one\n" } }, { "say": "wrote todo.md" }] },
{ "steps": [{ "edit": { "path": "todo.md", "old": "one", "new": "one, done" } }, { "say": "updated todo.md" }] }
]
} After paseo run and two paseo sends:
[User] start the todo list
[Write] /tmp/turns/todo.md
wrote todo.md
[User] and again
[Edit] /tmp/turns/todo.md
updated todo.md
[User] once more
[Edit] /tmp/turns/todo.md
updated todo.md This is how you script a conversation from the app's composer, where typing JSON is awkward.
Shell steps
With run: true the command runs with /bin/sh -c in the agent's cwd, with a 60 s timeout, and the tool call
reports its real output and exit code. A non-zero exit fails the tool call, not the turn. As with real adapters, a
file the command writes is not attributed to it (no Write item).
Real commands run only in a daemon ppd daemon up started: the provider checks that the daemon's home carries the .ppd-home marker. Installed into any other daemon, the step is refused without running, the tool call fails with
exit code 126, and the turn fails:
fake-agent refused to run this command: bash steps with run: true work only in a dev daemon started by `ppd daemon up`, and this daemon's home was not created by ppdMCP steps
An mcp step sends initialize, notifications/initialized and tools/call to the named server from the session
config, over HTTP (JSON or event-stream responses) or stdio, with a 10 s timeout per HTTP request or for the whole stdio
exchange. The tool's result is
the tool call's output.
"server": "*"calls the tool on every configured server, one tool call each. With none configured it does nothing.- An unknown server is a failed tool call, and the turn goes on:
no MCP server "nope" in the session config (have: none).
The server hooks recipe injects an MCP server with before("agent.create") and calls
it from a fake turn.
From tests
import { agentTimeline, pluginRows, runFakeAgentTurn, sendFakeAgentTurn } from "paseo-plugin-devkit";
const { agentId, status } = runFakeAgentTurn([{ write: { path: "a.ts", content: "x" } }, { say: "done" }], { cwd: workspace });
sendFakeAgentTurn(agentId, [{ mcp: { server: "my-tools", tool: "show_to_human", arguments: { title: "hi" } } }]);
const cards = pluginRows(agentTimeline(agentId), "turn-summary"); // plugin rows, which `paseo logs` omits | Export | What it does |
|---|---|
runFakeAgentTurn(script, { cwd, name, timeoutMs }) | Creates a fake-agent agent and runs one turn to the end (paseo run --json). Returns { agentId, status }. |
sendFakeAgentTurn(agentId, script, { name, timeoutMs }) | Sends one more turn to an existing agent and waits for it (paseo send). |
agentTimeline(agentId, { name }) | The canonical timeline, plugin rows included. |
pluginRows(timeline, kind) | The plugin rows of one kind, newest last. |
fakeAgentPrompt(script) | The prompt string for a script, to send from anywhere else. |
agentTimeline reads the timeline with the Paseo client inside the installed desktop app (it is not on npm), so it
needs the app or PASEO_APP. Plugin rows live in daemon memory, and hooks run asynchronously: poll briefly after a
turn. test/create-e2e.test.ts is a complete example.