SDK behaviors
The kit encodes several behaviors of the Claude Agent SDK (and the Claude Code CLI it runs) that its documentation doesn't state, or states differently. Each was checked against the real SDK; the source comments mark them as "confirmed empirically". They're re-checked on every SDK upgrade of the kit. If you build sessions by hand, you need to know them too.
Tools and permissions
disallowedToolswins over everything. A disallowed tool never reachescanUseTool, let alone runs.- Built-in tools in
tools/allowedToolsare approved beforecanUseToolis consulted, and bypass it. The SDK warns about it (CLAUDE_SDK_CAN_USE_TOOL_SHADOWED); the kit silences that one warning. mcp__*is not a valid wildcard inallowedToolsformcp__<server>__<tool>: only per-server wildcards match. HenceallowAnyMcpToolascanUseTool.- With an explicit
toolslist, theSkilltool must be listed to be offered, even though the skills are loaded. Without it, the model looks forSKILL.mdfiles by hand. - A
z.record()in an in-process tool's schema makes the SDK drop every tool of that server, silently: the server shows as connected, but its tools aren't offered. The kit's tools use lists of{ name, value }pairs instead. disallowedToolstakes a built-in tool out of the request, but not an MCP tool. A disallowedTodoWriteorBashleaves the context (3.4k and 1.9k input tokens fewer per call, measured); a disallowed in-process MCP tool is blocked, but its definition is still sent. Only not registering the server removes it.TodoWriteonly exists withCLAUDE_CODE_ENABLE_TASKS=0. By default the CLI offers itsTaskCreate,TaskUpdate,TaskListandTaskGettools instead and dropsTodoWritefrom the session, even whentoolsnames it; the kit passes that variable inenv(with the rest ofprocess.env). EachTodoWritecall carries the whole task list ({ todos: [{ content, status, activeForm }] }); its result only says it was saved.Readreads PDFs and images, but refuses DOCX and PPTX ("This tool cannot read binary files"). Henceextract_text.
Subagents
- A subagent can only use tools that are in the session's
tools. Otherwise the SDK refuses to spawn it ("would be spawned with zero tools… unrecognized [Bash]"). SoBashmust be in the session for a subagent to use it. - The built-in
general-purposesubagent type is always spawnable, whateveragentsdeclares, and inherits every tool of the session. Hence the type gate. agent_idis set in a hook's input only for calls made inside a subagent. The Bash gate uses it.- The
Agenttool runs subagents in the background by default, and a background subagent's result is lost if the host doesn't handle task messages.AgentDefinition.background: falsedoesn't change it; rewritingrun_in_backgroundin aPreToolUsehook does. Hence the foreground gate. - The
Agenttool's input is{ description, prompt, subagent_type }.
Messages and results
- A turn can report
subtype: "success"withis_error: true(an API error such as billing or access), with the real message inresult. Checkis_error(the kit'sfailed), not the subtype. - An unknown slash command produces a
systemmessage, not text: without handling it, the session looks silent. The kit turns it into aninfoevent, and the chats catch unknown commands before sending them. - An interrupted turn ends with an internal
[ede_diagnostic]error line; the kit filters it out. - Prompt suggestions arrive after their turn's
result. - The prompt iterable must stay open for a multi-turn session: the SDK closes the transport when it ends ("ProcessTransport is not ready for writing" on the next message). Hence
createInputQueue(). usageandtotal_cost_usdin a result are cumulative for the session in streaming input mode.
Configuration and context
- Without
settingSources, every source is loaded, the user's included, and alanguagesetting there becomes a system prompt rule that outranks the agent's. - The runner's auto-memory is loaded whatever
settingSourcessays;settings.autoMemoryEnabled: falsekeeps it out. - The git context (with the runner's git user name) comes with the git instructions;
settings.includeGitInstructions: falsedrops it. - The logged-in account's name and e-mail are always in the context (from
~/.claude.json); only a separateCLAUDE_CONFIG_DIRavoids it. - Plugin commands work without being in the
skillslist. getContextUsage()'s split between categories can mislead (skills counted as system tools when filtered), and before the first turn it counts in-process MCP tools as 0 tokens (15.5k estimated for a session whose first call took 23.7k); the API usage of a call is the reliable measure.
Sessions
- A
SessionStorereceives every transcript entry and is asked for them before resuming. A resumed session keeps its id and remembers the conversation, subagents included, even with the CLI's own copy deleted. The CLI's copy is always written while a store is in use. CLAUDE_CONFIG_DIRonly moves the root of the CLI's storage;<dir>/projects/<encoded cwd>/is fixed.
Terminal
- A non-TTY
readline.question()still writes its question to stdout, though it can never be answered; the kit skips readline entirely without a TTY. - readline tracks how many rows its prompt used (
prevRows), which goes stale when anything else writes to the terminal; the plain chat resets it after each write.