Full-featured user interaction simulator for terminal-based coding agents.
toll-free-harness models the complete user interaction surface of a coding agent CLI — prompting, answering questions, reviewing plans — as typed APIs. It spawns the agent in a local PTY, observes events through read-only hooks, and responds through the same keystroke channel a real user would.
Currently supports Claude Code. The framework is designed to add support for other coding agents whose headless modes do not fully expose the interactive user experience.
The name is a joke about toll booths around developer workflows.
Not an API proxy, credential-sharing service, or billing workaround. Users run their own local tools with their own accounts and must comply with those tools' terms.
Copy the migration guide and paste it to your coding agent:
# macOS
curl -s https://raw.githubusercontent.com/WeZZard/toll-free-harness/main/MIGRATION.md | pbcopy
# Linux
curl -s https://raw.githubusercontent.com/WeZZard/toll-free-harness/main/MIGRATION.md | xclip -selection clipboardAsk your agent to convert your claude -p scripts to use toll-free-harness.
No install needed. Replace claude -p with:
npx toll-free-harness claude -- -p "fix the failing tests"
npx toll-free-harness claude -- -p "explain this error" --allowedTools "Read"
cat build.log | npx toll-free-harness claude -- -p "what went wrong?"When -p is detected, the tool routes the session through an interactive PTY harness. Without -p, it passes through to claude directly.
pnpm add toll-free-harnessimport { ClaudeCodeSession } from "toll-free-harness";
const session = new ClaudeCodeSession({
args: ["--model", "opus"],
cwd: "/path/to/project",
prompt: "Fix the failing tests",
});
// Handle the agent's questions — return which option to select
session.onAskUserQuestion(async (event) => {
console.log(event.text);
console.log(event.questions[0]?.options.map((o, i) => `${i}: ${o.label}`));
return { selectedIndex: 0 };
});
// Handle plan review — approve or reject with feedback
session.onExitPlanMode(async (event) => {
console.log(event.planText.slice(0, 200));
return { decision: "approve" };
});
const result = await session.run();
// Send a follow-up prompt (with optional images)
session.sendPrompt("Now add tests for the fix", {
images: ["/tmp/screenshot.png"],
});The framework models three user interactions as dedicated typed APIs:
| Interaction | API | You provide | Library does |
|---|---|---|---|
| Prompting | sendPrompt(text, options?) |
Text + optional image paths | Injects keystrokes into PTY |
| Answering questions | onAskUserQuestion(handler) |
{ selectedIndex } |
Navigates and selects the option |
| Reviewing plans | onExitPlanMode(handler) |
{ decision: "approve" } or { decision: "reject", feedback } |
Approves or rejects via keystrokes |
There is no raw write() — the library translates your typed decisions into the correct keystrokes internally.
Observe agent events without sending data back:
session.onPreToolUse("Bash", (event) => {
console.log(`Running: ${event.toolInput?.command}`);
});
session.onPostToolUse("*", (event) => {
console.log(`Tool ${event.toolName} completed`);
});
session.onStop(() => {
console.log("Session ended");
});Available: onPreToolUse, onPostToolUse, onPermissionRequest, onStop, onUserPromptSubmit. All are read-only — hooks return {} internally and never send data back to the agent.
Wait for specific events with timeouts for deterministic test flows:
const event = await session.guardrail.expect(
{ kind: "pre_tool_use", toolName: "Bash" },
10_000,
);PreToolUse and Stop hooks are blocking — the agent waits for them. Keystrokes injected during a blocking hook callback buffer in PTY stdin and are consumed by the UI after the hook returns. PermissionRequest is non-blocking — the dialog renders in parallel.
run()generates a temporary plugin in/tmp/toll-free-plugin-<uuid>/containing a manifest, hook definitions, and a bundled hook client- Starts an HTTP server on a Unix domain socket (
/tmp/toll-free-<uuid>.sock) - Spawns the agent in a PTY with
--plugin-dir /tmp/toll-free-plugin-<uuid>/plus your args and prompt - The agent loads the plugin and fires hooks as it runs. The hook client posts events to the socket.
- Your interaction handlers and listeners receive events; responses go through PTY keystrokes
- On exit, the socket and plugin directory are cleaned up
No user-scope settings are modified. The plugin is self-contained and session-scoped.
Uses Node.js http.request({ socketPath }) for IPC — works on macOS, Linux, and Windows 10 1803+.
Apache-2.0