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_deltafor incoming text,thinking_deltafor thinking steps,input_json_deltafor tool input. From this you can extract thefile_pathand 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));