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.

Source-derived, not official docs — verify against your own CLI version.

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

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.