Claude·3 min lezen

Claude Agent SDK: goedkeuren, vragen en auto-modus in je eigen interface

Lessen uit een chat-interface op de Claude Agent SDK: canUseTool, AskUserQuestion-antwoorden, zichtbare auto-edits, plugin-MCP en streaming.

Voor de assistent in onze hosting bouwden we een eigen chat-interface op de Claude Agent SDK (TypeScript). De documentatie legt de basis goed uit, maar een paar dingen ontdek je pas als je het echt bouwt. Dit zijn onze lessen, getest met SDK-versie 0.3.270.

Toestemming vragen met canUseTool

Met canUseTool beslis je per tool-aanroep wat er gebeurt. Wij tonen een goedkeurkaart en wachten tot de gebruiker klikt:

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);   // jouw UI
    return answer.allow
      ? { behavior: "allow", updatedInput: input }
      : { behavior: "deny", message: "De gebruiker heeft deze stap afgewezen." };
  },
};

Luister naar signal: bij een interrupt wordt de vraag afgebroken, en je UI moet de kaart dan sluiten.

AskUserQuestion: het antwoord gaat via updatedInput

Claude stelt meerkeuzevragen via de tool AskUserQuestion. Die komt óók langs canUseTool. Behandel je hem als gewone goedkeuring ("toestaan"), dan krijgt Claude geen antwoord en stelt de vraag daarna opnieuw in platte tekst.

Het antwoord hoort in updatedInput.answers, met de vraagtekst als sleutel:

if (name === "AskUserQuestion") {
  const answers = await showChoiceButtons(input.questions);   // { "Wat wordt de prijs?": "€ 1.150" }
  return { behavior: "allow", updatedInput: { ...input, answers } };
}

Bij meerdere keuzes (multiSelect) scheid je de labels met komma's.

Auto-modus: je ziet de wijzigingen niet meer vanzelf

permissionMode: "auto" laat een classifier bepalen of een stap veilig is. Het werkt goed, maar goedgekeurde stappen komen niet langs canUseTool. Een bestandswijziging is dan onzichtbaar in je interface.

Onze oplossing: een PostToolUse-hook die elke uitgevoerde wijziging alsnog meldt, en een PreToolUse-hook om de oude inhoud te bewaren voor een 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 {};
  }] }],
}

Moet iets altijd een vraag blijven, ook in auto-modus? Geef in een PreToolUse-hook permissionDecision: "ask" terug. Wij doen dat voor het terugzetten van backups.

strictMcpConfig blokkeert ook plugin-MCP

Draait de SDK onder een gebruiker die op claude.ai connectors heeft (Gmail, Slack), dan laadt de sessie die mee. Met strictMcpConfig: true voorkom je dat. Maar: dan laden ook de MCP-servers uit je plugins niet meer.

Geef ze daarom expliciet door. Lees de .mcp.json van je plugins en zet die in mcpServers:

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

De tools heten daarna mcp__<server>__<tool>, handig om leesacties automatisch toe te staan.

Live meekijken met includePartialMessages

Zonder streaming ziet de gebruiker 30 seconden "bezig…" terwijl Claude een groot bestand schrijft. Met includePartialMessages: true krijg je stream_event-berichten:

  • text_delta voor tekst die binnenkomt,
  • thinking_delta voor denkstappen,
  • input_json_delta voor tool-invoer. Hieruit haal je vroeg het file_path en het aantal bytes, voor een regel als "Schrijft index.html · 14 KB".

Let op dubbele tekst: het volledige assistant-bericht kan binnenkomen vóór de laatste delta's. Leeg je delta-buffer zodra het complete tekstblok er is.

Een sessie hervatten

Bewaar het session_id uit het system/init-bericht. Met resume: sessionId gaat een nieuw proces verder met dezelfde context. Claude weet dan nog wat er besproken is. De weergave van het oude gesprek moet je zelf opslaan: de SDK geeft je bij hervatten geen geschiedenis terug.

Houd de runner netjes

Draai je de SDK in een apart proces met stdin/stdout naar je server, laat het proces dan stoppen als stdin sluit. Anders blijven er na een herstart van je server "wezen" draaien. Bij ons liep dat op tot vier processen en ruim 1 GB geheugen voordat we het zagen.

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

Oprichter van Invoker. Ruim twintig jaar hosting, systeembeheer en webdevelopment; bouwt de Claude-hosting zelf en test alles eerst op de eigen servers.

over maarten