The undocumented --permission-prompt-tool contract
Claude Code's official docs confirm the flag exists but don't publish the JSON shape it speaks. Here's what's actually on the wire, source-derived from the published CLI bundle — and a fallback disclaimer worth taking seriously: this is not an official spec, and it can change without notice.
On this page
Why this needs reverse-engineering at all
As of this writing, --permission-prompt-tool's existence and its one-line purpose
("point headless mode at an MCP tool that answers permission prompts instead of the interactive
UI") are documented in the CLI reference. The actual JSON schema the tool must accept and return
is not — two open documentation requests on the CLI's issue tracker ask for exactly this (issues
#1175 and #24595 in anthropics/claude-code). Until that lands, the only way to know the exact
contract is to read what the CLI itself sends and expects, which is what follows.
The input shape
Claude Code calls your tool with a single tools/call, whose arguments
are exactly:
{
"tool_name": "Edit",
"input": { "file_path": "...", "old_string": "...", "new_string": "..." },
"tool_use_id": "toolu_01..."
}
tool_name is whatever built-in or MCP tool triggered the prompt (Bash,
Edit, Write, an MCP tool's mcp__server__tool name — anything
that would otherwise have shown an interactive permission dialog). input is that
tool's own arguments, unmodified. tool_use_id lets you correlate this call back to a
specific tool-use block in the transcript if you're logging or displaying it.
The output shape
The tool must return exactly one type:"text" content block, whose
text is a JSON string that parses to one of two shapes:
// allow — "updatedInput" is a required key; {} means "use the original input unchanged"
{ "behavior": "allow", "updatedInput": { "...": "..." }, "updatedPermissions": [], "interrupt": false }
// deny
{ "behavior": "deny", "message": "...", "interrupt": false }
updatedInput being required (even as an empty object) is easy to miss and produces
a confusing failure if you skip it — always include the key on an allow, even when you have
nothing to change about the original call. interrupt: true asks Claude Code to stop
the current turn rather than just deny this one tool call; leave it false for an
ordinary per-call decision. updatedPermissions lets you return permission-rule updates
(e.g. "remember this decision") in the same shape the interactive UI's own "always allow" produces
— an empty array is the common case if you're not managing persistent rules from your tool.
Beyond those two required keys per branch, the CLI's own parser only reads behavior
and otherwise ignores unknown keys — which is useful if you want your tool to carry extra
information (a machine-readable reason for a deny, say) for whatever's reading the
result downstream, as long as you don't rely on the CLI itself acting on it.
Gotchas
MCP_TIMEOUT(env var, milliseconds, default 30000) is how long Claude Code will wait for your MCP server to complete its connection handshake before the first turn even starts. If your permission-prompt server is slow to cold-start, raise this — a session that can't connect its permission-prompt tool within the timeout fails before any tool call is even attempted._meta["anthropic/requiresUserInteraction"]: as of CLI ≥2.1.199, anallowfrom your prompt tool is silently converted todenyfor any MCP tool flagged with this metadata key — your prompt tool can't rubber-stamp those regardless of what it returns. This exists to stop a permission-prompt adapter from being used to bypass a tool that's specifically marked as needing a human directly in front of the interactive UI.- The literal server name
"stdio"is reserved — it's what the Claude Agent SDK's owncanUseToolwiring uses internally (see the Agent SDK guide) when it launches the CLI under the hood. Name your own MCP server anything else. - Your tool is removed from Claude's own visible tool list once wired up this way — the model can't call it directly, only Claude Code's own permission machinery invokes it. Don't design a prompt that expects the model to call it itself.
A minimal do-it-yourself MCP server
The contract above is small enough to implement directly without any framework — here's a
bare-bones stdio MCP server in Node that always denies with a fixed message (swap the body of
decide() for whatever actually reaches a human):
#!/usr/bin/env node
// permission-prompt-server.mjs — minimal stdio MCP server implementing permission_prompt
import { createInterface } from "node:readline";
const rl = createInterface({ input: process.stdin });
function respond(id, result) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
}
async function decide({ tool_name, input, tool_use_id }) {
// Replace this with a real check: relay to a human, consult a policy file, etc.
const allowed = tool_name === "Read"; // toy example: only ever allow reads
const payload = allowed
? { behavior: "allow", updatedInput: {}, updatedPermissions: [], interrupt: false }
: { behavior: "deny", message: `denied: ${tool_name} needs manual approval`, interrupt: false };
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
}
rl.on("line", async (line) => {
const msg = JSON.parse(line);
if (msg.method === "initialize") {
respond(msg.id, {
protocolVersion: "2025-06-18",
capabilities: { tools: {} },
serverInfo: { name: "diy-permission-prompt", version: "0.1.0" },
});
} else if (msg.method === "tools/list") {
respond(msg.id, {
tools: [{ name: "permission_prompt", description: "Decides tool permission prompts.",
inputSchema: { type: "object", properties: {
tool_name: { type: "string" }, input: { type: "object" }, tool_use_id: { type: "string" },
}, required: ["tool_name", "input", "tool_use_id"] } }],
});
} else if (msg.method === "tools/call" && msg.params.name === "permission_prompt") {
respond(msg.id, await decide(msg.params.arguments));
}
});
claude -p --mcp-config '{"mcpServers":{"gate":{"type":"stdio","command":"node","args":["permission-prompt-server.mjs"]}}}' \
--permission-prompt-tool mcp__gate__permission_prompt "read the README"
A real implementation needs somewhere durable to hold a pending decision while a human is paged — this toy example decides instantly and never actually blocks, which sidesteps the hardest part of the real problem: holding state across a possibly multi-minute wait without leaking memory or losing the request if your process restarts.
The hosted option
Building and keeping that durable-wait server running is a small but real ongoing chore —
provisioning it somewhere reachable, storing pending requests, wiring it to a channel that will
actually reach a human. pendnt's permission_prompt MCP tool implements exactly this
contract as a hosted server (email/Telegram/webhook delivery, a configurable wait via
PERMISSION_PROMPT_WAIT_S, a machine-readable reason field on every deny so
a cron wrapper can tell a timeout from an explicit no — see
the cron checklist) if you'd rather not run the DIY version
above yourself. The contract is the same either way; this is just one implementation of it, not the
only one.