Piyush Gupta

Lessons from building an agent platform on top of Claude SDK

I came across Agent SDK a while ago and since then wanted to experiment with it to see how it works and what can be built. Though there’s a lot of chatter around Claude Code but I hardly see people talking about Agent SDK that powers Claude Code and now Claude Cowork.

I think so many cool things can be built on top of it and this project was attempt to learn what it has to offer.

This post is not an introduction to agent SDK but the patterns I had to build on top of the SDK and the lessons I learned the hard way by breaking things.

Here's the github repo.

Why I ditched the SDK's built-in tools for Docker

The SDK's built-in tools (Read, Write, Bash, Glob, Grep) execute locally on your machine and that's their purpose. For local development, that's exactly what you want.

But for a platform where users define agents that can do whatever they are asked to do, that’s not a good idea. An agent could read sensitive files, write to system directories, or run destructive commands.

So I had to disable the SDK's built-in tools entirely and route everything through Docker.

// In query() options:
disallowedTools: ['Read', 'Write', 'Bash', 'Edit', 'Glob', 'Grep', 'NotebookEdit']

// Replaced by my Docker MCP server:
mcpServers: {
  docker: createDockerMcpServer(containerId)
}

The agent sees tools named mcp__docker__Readmcp__docker__Bash, etc. They look like the SDK's tools but execute inside an ephemeral container. The host filesystem is never touched.

You must explicitly disable SDK's built-in tools. If you just add your MCP server, both versions exist. The agent might call the SDK's Read (which hits your host) instead of mcp__docker__Read (which hits the container). I thought adding an MCP server would replace the built-in tools. It doesn't. You have to disable them yourself:

const SDK_BUILTIN_SANDBOX_TOOLS = [
  'Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Glob', 'Grep', 'NotebookEdit'
];
// ALWAYS disable these to prevent host filesystem access
disallowedToolsList.push(...SDK_BUILTIN_SANDBOX_TOOLS);

Three security layers I had to add

Disabling SDK tools was step one. But pretty quickly I realized a Docker container by itself isn’t secure either. An agent could still rm -rf / or read sensitive files.

I added three security layers that the SDK has no concept of:

Layer 1: Allowlisting commands instead of blocklisting them

Instead of blocklisting dangerous commands (which always misses something), I allowlist safe ones:

const ALLOWED_COMMANDS = new Set([
  'ls', 'cat', 'head', 'tail', 'grep', 'find', 'awk', 'sed', 'jq',
  'sort', 'uniq', 'cut', 'diff', 'git', 'curl', 'python3', 'node'
]);

Unknown commands are blocked by default. Even within containers, agents can only execute read-oriented operations.

Layer 2: Blocking sensitive files even in safe directories

Even in allowed directories, some files should never be readable:

const SENSITIVE_FILE_PATTERNS = [
  /\.env($|\.)/i,           // .env, .env.local
  /\.pem$/, /\.key$/,       // Private keys
  /id_rsa/, /id_ed25519/,   // SSH keys
  /\.aws\/credentials/,     // AWS creds
  /service-account.*\.json/ // Cloud service accounts
];

Layer 3: Sandboxing user code with Node's vm module

Users can define custom tool handlers and hooks as code strings. I use Node's vm module with a restricted context. Only safe built-ins like JSONMath, and Array are accessible. Dangerous globals like processrequire, and fetch are explicitly blocked.

Things the SDK docs don't tell you

Here are the subtle behaviours I discovered through experimentation.

MCP servers inherit automatically

I spent hours trying to pass MCP servers to subagents explicitly. The SDK documentation mentions that subagents inherit tools ("If omitted, inherits all tools"), but it took me a while to realize this applies to MCP servers too. MCP servers registered at the parent level are automatically inherited by subagents:

// This is what I tried (WRONG):
agents: {
  FileManager: {
    mcpServers: { docker: dockerMcpServer }  // Doesn't work!
  }
}

// What actually works:
mcpServers: {
  docker: dockerMcpServer  // Register at parent level
},
agents: {
  FileManager: {
    // Subagents inherit MCP servers automatically - no need to pass them
    tools: ['mcp__docker__Read', 'mcp__docker__Bash']
  }
}

You have to register MCP servers at the orchestrator level, then use allowedTools on subagents to control which ones they can use.

disallowedTools is global and canUseTool is per-call

I wanted orchestrators blocked from using Docker tools, but subagents allowed. My first attempt was to add Docker tools to disallowedTools with some conditional logic.

But it blocked everyone, including the subagents.

This led to the realization that disallowedTools is a session-wide blocklist. To block tools only for the orchestrator while allowing subagents, you need the canUseTool callback, which runs per tool call.

canUseTool: async (toolName, input, context) => {
  if (context?.agentID) {
    // Subagent calling - allow
    return { behavior: 'allow', updatedInput: input };
  }
  // Orchestrator calling - block
  return { behavior: 'deny', message: 'Delegate to a subagent' };
}

Orchestrators need containers even if they don't touch files

My orchestrators only use TaskTodoWrite, and AskUserQuestion. They don't touch files. So I skipped container creation for them.

That broke everything. Subagents do use file tools, and they run in the orchestrator's context. No container means subagents can't access Docker MCP tools.

// WRONG: Only create container if parent uses sandbox tools
if (sandboxTools.length > 0 && containerId) { ... }

// RIGHT: Also create for orchestrators with subagents
const needsDockerForSubagents = isOrchestrator && containerId;
if ((sandboxTools.length > 0 || needsDockerForSubagents) && containerId) { ... }

Project cache paths will leak data if you mount the whole directory

The SDK caches large tool results at ~/.claude/projects/{encoded-path}/. I needed to mount this for agents to access cached content.

First attempt was to mount ~/.claude/projects/ into the container. But that exposes every project's cache, not just the current one.

I had to pass the specific project cache folder and mount only that.

const projectPath = path.resolve(process.cwd());
const projectCacheName = projectPath.replace(/\//g, '-'); // /Users/foo/bar -> -Users-foo-bar
const projectCacheDir = path.join(os.homedir(), '.claude', 'projects', projectCacheName);
// Mount only THIS project's cache, not all projects
mounts.push({ source: projectCacheDir, target: `/claude-cache/projects/${projectCacheName}`, readonly: true });

Hook inputs are nested

The SDK documentation does show the tool_input field, but when I first implemented hooks, I missed it. For PreToolUse hooks, the actual tool arguments are nested inside input.tool_input, not in input directly:

// WRONG:
hooks: [async (input) => {
  const filePath = input.file_path;  // undefined!
}]

// RIGHT:
hooks: [async (input) => {
  const toolInput = input.tool_input || {};
  const filePath = toolInput.file_path;  // correct!
}]

The hook receives a PreToolUseHookInput object. The tool's actual parameters are in .tool_input. The documentation shows this in examples, but it's easy to miss if you're skimming.

Result success can be undefined

I had a bug where successful completions were logged as failures:

// WRONG:
const status = message.success ? 'SUCCESS' : 'FAILED';

// RIGHT:
const status = message.success === false ? 'FAILED' : 'SUCCESS';

The success field can be true (explicit success), false (explicit failure), or undefined (normal completion). undefined is not a failure, it's the default for successful runs.

Skills won't load without the right settingSources

I enabled skills in my agent config, but they weren't loading. The SDK wasn't finding my SKILL.md files.

The missing piece was settingSources must include 'project' for SDK skill discovery:

// Skills need 'project' in settingSources to load .claude/skills/*.md
if (hasSkillsEnabled) {
  settingSources.add('project');
  settingSources.add('user');
}

How I figured out context.agentID

The SDK documentation shows how to detect subagent messages via parent_tool_use_id. What it doesn't document: the context object passed to canUseTool contains agentID when a subagent is making the call.

canUseTool: async (toolName, input, context) => {
  console.log(`tool=${toolName}, agentID=${context?.agentID}`);
  // Orchestrator: agentID is undefined
  // Subagent "FileManager": agentID is "FileManager"
}

This single field lets you:

Adding OAuth connectors

SDK doesn't handle OAuth flows automatically. You can pass access tokens via headers, but you're on your own for the OAuth implementation i.e. token encryption, refresh handling, and pre-built integrations.

I built a complete OAuth connector system for Gmail, Slack, Notion, and GitHub:

  1. PKCE code verifier and challenge generation
  2. Provider OAuth consent redirect
  3. Token exchange and AES-256-GCM encryption
  4. Automatic refresh before expiration
  5. MCP server that exposes tools like gmail_listdrive_searchslack_send

An agent can now search emails, read Drive documents, or post to Slack—all through unified MCP tool calls, with no exposure of raw tokens.

Writing errors that help agents fix themselves

Early on, agents got stuck when tools failed. Then I added details in the errors that include recovery instructions. And this worked pretty well. This info became something the agent can act on.

// When output exceeds limit:
`[Truncated: Result exceeded 50,000 characters. To retrieve complete data:
- Use pagination (smaller page_size)
- Filter results more specifically]`

// When a host path is detected:
`Path "/Users/alice/project" is a host path.
Files must be in /scratch. Use FileManager to clone repos first.`

// When permission is denied:
`Error: Files can only be written to /scratch directory.
Read access: /scratch, /skills, /claude-cache.`

Every orchestrator gets a FileManager

Every orchestrator needs file setup before analysis. Users would forget to add a file-handling subagent and the orchestrator would fail.

I auto inject FileManager into every orchestrator that doesn't already have one:

if (!hasFileManager && isOrchestrator) {
  config.subagents = {
    FileManager: {
      description: 'Clone repos, download files, prepare /scratch workspace.',
      prompt: 'You are FileManager. Set up files in /scratch.',
      model: 'haiku'  // Fast and cheap
    },
    ...config.subagents
  };
}

Every subagent gets a turn limit

I add maxTurns: 15 to every subagent as a safety limit. Without it, a confused subagent could run forever, consuming tokens until budget exhaustion.

Lessons from breaking things

Here are the condensed lessons:

  1. Disable what you're replacing. Adding an MCP server doesn't remove SDK built-in tools. You have to explicitly disable them.
  2. MCP servers inherit automatically. Register servers at the parent level and control subagent access via allowedTools.
  3. disallowedTools is global, canUseTool is per-call. Use the callback for per-agent enforcement.
  4. Containers are for the session, not the agent. Orchestrators need containers even if they don't use file tools. Their subagents do.
  5. Read the types along with the docs. Some features like context.agentID in canUseTool aren't prominently documented but are in the TypeScript definitions. The docs do show tool_input in hook examples. I just missed it initially.
  6. Error messages are agent UX. Tell the agent what went wrong and how to fix it.
  7. Auto inject the obvious stuff. FileManager, platform guidelines, turn limits. If everyone needs it, it's probably good to abstract away.
  8. Security layers. System prompts can be ignored, tool filters can be bypassed. Runtime canUseTool checks are the final gate.