Claude·3 min read

Claude Agent SDK: approvals, questions and auto mode in your own interface

Lessons from building a chat interface on the Claude Agent SDK: canUseTool, AskUserQuestion answers, visible auto-edits, plugin MCP and live streaming.

For the assistant in our hosting we built our own chat interface on the Claude Agent SDK (TypeScript). The documentation explains the basics well, but some things you only discover when you actually build it. These are our lessons, tested with SDK version 0.3.270.

Asking for permission with canUseTool

With canUseTool you decide per tool call what happens. We show an approval card and wait for the user to click:

const options = {
  permissionMode: "default",
  canUseTool: async (name, input, { signal, toolUseID }) => {
    if (["Read", "Glob", "Grep"].includes(name)) return { behavior: "allow", updatedInput: input };
    const answer = await askUser(name, input, signal);   // your UI
    return answer.allow
      ? { behavior: "allow", updatedInput: input }
      : { behavior: "deny", message: "The user rejected this step." };
  },
};

Listen to signal: on an interrupt the question is aborted, and your UI should close the card.

AskUserQuestion: the answer goes through updatedInput

Claude asks multiple-choice questions through the AskUserQuestion tool. That also goes through canUseTool. If you treat it as a regular approval ("allow"), Claude gets no answer and asks the question again in plain text afterwards.

The answer belongs in updatedInput.answers, keyed by the question text:

if (name === "AskUserQuestion") {
  const answers = await showChoiceButtons(input.questions);   // { "What should the price be?": "€ 1,150" }
  return { behavior: "allow", updatedInput: { ...input, answers } };
}

For multiple choices (multiSelect) separate the labels with commas.

Auto mode: you no longer see the changes by default

permissionMode: "auto" lets a classifier decide whether a step is safe. It works well, but approved steps do not go through canUseTool. A file change is then invisible in your interface.

Our fix: a PostToolUse hook that still reports every applied change, and a PreToolUse hook that keeps the old content for a diff:

hooks: {
  PostToolUse: [{ matcher: "Edit|Write|MultiEdit", hooks: [async (h) => {
    if (!shownAsCard.has(h.tool_use_id)) emit({ type: "auto-edit", tool: h.tool_name, input: h.tool_input });
    return {};
  }] }],
}

Should something always stay a question, even in auto mode? Return permissionDecision: "ask" from a PreToolUse hook. We do that for restoring backups.

strictMcpConfig also blocks plugin MCP

If the SDK runs as a user who has connectors on claude.ai (Gmail, Slack), the session loads them too. strictMcpConfig: true prevents that. But then the MCP servers from your plugins don't load anymore either.

So pass them explicitly. Read your plugins' .mcp.json and put it in mcpServers:

mcpServers: JSON.parse(fs.readFileSync(`${pluginDir}/.mcp.json`, "utf8")).mcpServers,
strictMcpConfig: true,

The tools are then called mcp__<server>__<tool>, which is handy for auto-allowing read-only actions.

Watching live with includePartialMessages

Without streaming the user sees "working…" for 30 seconds while Claude writes a large file. With includePartialMessages: true you get stream_event messages:

  • text_delta for incoming text,
  • thinking_delta for thinking steps,
  • input_json_delta for tool input. From this you can extract the file_path and byte count early, for a line like "Writing index.html · 14 KB".

Watch out for duplicate text: the complete assistant message can arrive before the last deltas. Clear your delta buffer as soon as the complete text block is in.

Resuming a session

Store the session_id from the system/init message. With resume: sessionId a new process continues with the same context. Claude then still knows what was discussed. You have to store the display of the old conversation yourself: the SDK doesn't return history when resuming.

Keep the runner tidy

If you run the SDK in a separate process with stdin/stdout to your server, make the process exit when stdin closes. Otherwise "orphans" keep running after your server restarts. For us that grew to four processes and over 1 GB of memory before we noticed.

readline.createInterface({ input: process.stdin }).on("close", () => process.exit(0));
MK
Maarten Keizer

Founder of Invoker. Over twenty years in hosting, system administration and web development; builds the Claude hosting himself and tests everything on our own servers first.

about maarten