Build an Incident Collector with the Claude Agent SDK
Build an AI Incident Collector with the Claude Agent SDK in TypeScript. First as a single agent that searches for and verifies AI incidents, then as a multi-agent system that delegates discovery and verification to subagents and merges their work.
What you’ll build
Version 1 — single agent:
Single Incident Collector Agent → searches for AI incidents → verifies evidence → writes data/incidents.jsonl → writes 01-incident-registry.mdVersion 2 — multi-agent with subagents:
Incident Collector parent → spawns Agent subagents for discovery and verification → merges their results → writes the final artifactsBoth versions share the same tools, system prompt, and output shape. The conversion to multi-agent is mechanical once the single-agent version works.
Total time: 60 – 90 minutes, depending on familiarity with TypeScript and the Claude SDK.
Prerequisites
- Node.js 20 or later — verify with
node --version - TypeScript familiarity (basic — types, imports, async)
- An Anthropic API key, or Claude via Vertex / Bedrock — see Claude Code setup for the routing options
- ~200 MB of free disk space for
node_modules
1. Create the Project
Create a new folder:
mkdir claude-agent-sdk-trainingcd claude-agent-sdk-trainingCreate package.json:
{ "name": "claude-agent-sdk-training", "version": "0.1.0", "private": true, "type": "module", "scripts": { "start": "tsx src/index.ts", "typecheck": "tsc --noEmit" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "latest" }, "devDependencies": { "@types/node": "latest", "tsx": "latest", "typescript": "latest" }}Install:
pnpm installCreate tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["node"] }, "include": ["src/**/*.ts"]}Create the source folder:
mkdir -p src2. Import the SDK
Create src/index.ts.
Start with the SDK imports:
import { mkdir } from "node:fs/promises";import { resolve } from "node:path";
import { query, type AgentDefinition, type Options, type SDKMessage, type SDKResultMessage,} from "@anthropic-ai/claude-agent-sdk";What this does:
-
query— Starts one Claude Agent SDK run. -
Options— Configures model, working directory, tools, permissions, prompts, and subagents. -
SDKMessage— Represents streamed events from the agent loop. -
SDKResultMessage— Represents the final result of the run. -
AgentDefinition— Defines a subagent that can be called through theAgenttool.
3. Add Runtime Configuration
Paste this below the imports:
type Mode = "single" | "multi";
interface RunConfig { mode: Mode; workspaceDir: string; outputDir: string; incidentCount: number; recencyWindow: string; model: string; subagentModel: string;}
function resolveRunConfig(mode: Mode): RunConfig { const workspaceDir = resolve(process.cwd(), "outputs", mode);
return { mode, workspaceDir, outputDir: resolve(workspaceDir, "workshop-outputs"), incidentCount: Number.parseInt(process.env.INCIDENT_COUNT ?? "5", 10), recencyWindow: process.env.INCIDENT_RECENCY ?? "last 6 months", model: process.env.CLAUDE_AGENT_SDK_MODEL ?? "opus", subagentModel: process.env.CLAUDE_AGENT_SDK_SUBAGENT_MODEL ?? "sonnet", };}
async function prepareWorkspace(config: RunConfig): Promise<void> { await mkdir(resolve(config.outputDir, "data"), { recursive: true });}What this does:
- We keep the working directory isolated under
outputs. - This matters because the agent can write files.
- The model and incident count are environment variables so the workshop can change them without editing code.
4. Add Tool Groups
Paste this below the config:
const SAFE_RESEARCH_TOOLS = [ "WebSearch", "WebFetch", "Read", "Glob", "Grep",] as const;
const WRITING_TOOLS = ["Write", "Edit"] as const;What this does:
- The Incident Collector needs search and fetch tools to gather evidence.
- It needs write tools only to produce final artifacts.
- Later, we will give subagents only research tools so they cannot write final files.
5. Add the Shared System Prompt
Paste:
function buildSystemAppend(config: RunConfig): string { return [ "You are the Incident Collector Agent for an AI Incident Analysis and Guardrail Design workshop.", "Collect only incidents relevant to GenAI, LLM applications, coding agents, agentic workflows, AI infrastructure, or AI development guardrails.", "Reject broad generic AI incidents that do not teach guardrail lessons for LLM or agent systems.", "Never invent source URLs, dates, affected organizations, impacts, CVEs, vendors, or statistics.", "If evidence is weak or conflicting, reject the case or mark confidence clearly.", `Requested recency window: ${config.recencyWindow}.`, ].join("\n");}What this does:
- This is the stable role instruction.
- The user task can change, but the agent identity and evidence rules should stay stable.
6. Add the Single-Agent Prompt
Paste:
function buildSingleAgentPrompt(config: RunConfig): string { return [ `Collect ${config.incidentCount} high-quality incidents for the workshop.`, "", "Work method:", "1. Search for candidate incidents from public sources.", "2. Fetch and verify source pages before accepting an incident.", "3. Deduplicate overlapping reports.", "4. Keep only incidents relevant to LLM, GenAI, coding-agent, or agentic AI guardrails.", "5. Write the final artifacts yourself.", "", "Output files, relative to the current working directory:", "- `workshop-outputs/data/incidents.jsonl`", "- `workshop-outputs/01-incident-registry.md`", "", "Each JSONL record must include:", "- id", "- title", "- date", "- sources", "- affectedOrganization", "- affectedSystem", "- aiSystemType", "- failureCategory", "- severity", "- summary", "- observedImpact", "- guardrailRelevance", "- evidenceQuality", "- tags", "- downstreamNotes", "", "If you cannot find enough verified incidents, write fewer verified incidents and explain the gap. Do not pad with weak or invented incidents.", ].join("\n");}What this does:
- The prompt describes the work.
- The options describe the runtime boundary.
- Keep these separate in your design.
7. Add the SDK Runner
Paste:
const MAX_ASSISTANT_CHARS = 420;const MAX_TOOL_INPUT_CHARS = 180;const MAX_TOOL_RESULT_CHARS = 650;
interface ToolCallContext { toolName: string; scope: string; subagentName: string | null;}
async function runClaudeAgent( label: string, prompt: string, options: Options,): Promise<SDKResultMessage> { let result: SDKResultMessage | null = null; const toolCalls = new Map<string, ToolCallContext>();
logLine(label, "START", "agent run");
for await (const message of query({ prompt, options })) { if (message.type === "system") { logLine(label, "SYS", "session initialized"); continue; }
if (message.type === "assistant") { logAssistantMessage(label, message, toolCalls); continue; }
if (message.type === "user") { logToolResultMessage(label, message, toolCalls); continue; }
if (message.type === "result") { result = message; logLine( label, "DONE", `${message.subtype} turns=${message.num_turns} cost=$${message.total_cost_usd.toFixed(4)}`, ); } }
if (!result) { throw new Error(`[${label}] finished without a result`); }
if (result.subtype !== "success") { throw new Error(`[${label}] failed: ${result.subtype}`); }
return result;}
function logAssistantMessage( rootLabel: string, message: Extract<SDKMessage, { type: "assistant" }>, toolCalls: Map<string, ToolCallContext>,): void { const scope = resolveScope(rootLabel, message.parent_tool_use_id, toolCalls); for (const block of message.message.content) { if (block.type === "text") { logLine(scope, "A", truncateOneLine(block.text, MAX_ASSISTANT_CHARS)); continue; }
if (block.type === "tool_use") { const subagentName = extractSubagentName(block.input); const toolScope = subagentName ? `${rootLabel}/${subagentName}` : scope; toolCalls.set(block.id, { toolName: block.name, scope, subagentName, });
logLine(scope, "T", formatToolCall(block.name, block.input)); if (block.name === "Agent" && subagentName) { logLine(toolScope, "AGENT", "delegated"); } } }}
function logToolResultMessage( rootLabel: string, message: Extract<SDKMessage, { type: "user" }>, toolCalls: Map<string, ToolCallContext>,): void { if (message.tool_use_result !== undefined) { const context = message.parent_tool_use_id ? toolCalls.get(message.parent_tool_use_id) : undefined; logToolResult(rootLabel, context, message.tool_use_result); }
const content = message.message.content; if (Array.isArray(content)) { for (const block of content) { if (!isRecord(block) || block.type !== "tool_result") continue;
const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : null; const context = toolUseId ? toolCalls.get(toolUseId) : undefined; logToolResult(rootLabel, context, block.content, block.is_error === true); } }}
function logToolResult( rootLabel: string, context: ToolCallContext | undefined, content: unknown, isError = false,): void { const resultScope = context?.subagentName ? `${rootLabel}/${context.subagentName}` : context?.scope ?? rootLabel; const toolName = context?.toolName ?? "tool"; const status = isError ? " error" : ""; const preview = truncateOneLine( formatToolResultContent(content), MAX_TOOL_RESULT_CHARS, ); logLine(resultScope, "R", `${toolName}${status}: ${preview}`);}
function formatToolCall(toolName: string, input: unknown): string { if (toolName === "Agent") { const subagentName = extractSubagentName(input) ?? "agent"; const description = extractString(input, ["description"]); const suffix = description ? ` ${truncateOneLine(description, MAX_TOOL_INPUT_CHARS)}` : ""; return `Agent -> ${subagentName}${suffix}`; }
const summary = summarizeToolInput(input); return summary ? `${toolName} ${summary}` : toolName;}
function summarizeToolInput(input: unknown): string { if (!isRecord(input)) { return truncateOneLine(formatUnknown(input), MAX_TOOL_INPUT_CHARS); }
const preferredKeys = [ "query", "url", "file_path", "path", "pattern", "command", ]; for (const key of preferredKeys) { if (typeof input[key] === "string") { return `${key}=${truncateOneLine(input[key], MAX_TOOL_INPUT_CHARS)}`; } }
const firstScalar = Object.entries(input).find(([, value]) => typeof value === "string" || typeof value === "number" || typeof value === "boolean" ); if (!firstScalar) return "";
const [key, value] = firstScalar; return `${key}=${truncateOneLine(String(value), MAX_TOOL_INPUT_CHARS)}`;}
function extractSubagentName(input: unknown): string | null { return extractString(input, [ "subagent_type", "agent_type", "agent", "name", ]);}
function extractString(input: unknown, keys: string[]): string | null { if (!isRecord(input)) return null;
for (const key of keys) { if (typeof input[key] === "string" && input[key].trim().length > 0) { return input[key]; } }
return null;}
function resolveScope( rootLabel: string, parentToolUseId: string | null, toolCalls: Map<string, ToolCallContext>,): string { if (!parentToolUseId) return rootLabel;
const context = toolCalls.get(parentToolUseId); if (!context) return rootLabel;
return context.subagentName ? `${rootLabel}/${context.subagentName}` : context.scope;}
function formatToolResultContent(content: unknown): string { if (typeof content === "string") return content;
if (Array.isArray(content)) { return content.map(formatToolResultContentBlock).join("\n"); }
return formatUnknown(content);}
function formatToolResultContentBlock(block: unknown): string { if (!isRecord(block)) return formatUnknown(block);
if (block.type === "text" && typeof block.text === "string") { return block.text; }
if (block.type === "image") { return "[image result]"; }
return formatUnknown(block);}
function formatUnknown(value: unknown): string { if (typeof value === "string") return value;
try { return JSON.stringify(value, null, 2); } catch { return String(value); }}
function truncateOneLine(value: string, maxChars: number): string { const compact = value.replace(/\s+/g, " ").trim(); if (compact.length <= maxChars) return compact; return `${compact.slice(0, maxChars)}...`;}
function logLine(scope: string, kind: string, message: string): void { console.info(`[${scope}] ${kind} ${message}`);}
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null;}What this does:
- This is the SDK execution loop.
query()starts the agent.for awaitreceives live messages.- Logs are intentionally compact:
-
START: run started
-
SYS: SDK session initialized
-
A: assistant message
-
T: tool call
-
R: tool result
-
AGENT: subagent delegation
-
DONE: final result
- In multi-agent mode, subagent lines use a scoped label such as
[multi/incident-verifier]. - In LifeOSAI, this stream becomes live run messages, status updates, file updates, and UI events.
8. Build the Single-Agent Main Function
Paste:
async function runSingleAgent(): Promise<void> { const config = resolveRunConfig("single"); await prepareWorkspace(config);
const options: Options = { cwd: config.workspaceDir, model: config.model, permissionMode: "acceptEdits", maxTurns: 18, persistSession: false, settingSources: [], systemPrompt: { type: "preset", preset: "claude_code", append: buildSystemAppend(config), }, tools: [...SAFE_RESEARCH_TOOLS, ...WRITING_TOOLS], allowedTools: [...SAFE_RESEARCH_TOOLS, ...WRITING_TOOLS], };
await runClaudeAgent( "single-incident-collector", buildSingleAgentPrompt(config), options, );
console.info(`\nSingle-agent output: ${config.outputDir}`);}Important options:
-
cwd— The agent’s working directory. -
permissionMode— How tool permissions behave. -
maxTurns— Safety bound for the agent loop. -
systemPrompt— The stable role instruction. -
tools— What the agent can use. -
allowedTools— What can be auto-approved.
9. Add the Entrypoint
Paste this at the end:
runSingleAgent().catch((error: unknown) => { console.error(error); process.exitCode = 1;});Run:
pnpm startInspect:
ls outputs/single/workshop-outputsExpected:
01-incident-registry.mddata10. Convert to Multi-Agent
At this point:
- The single agent works, but one agent is doing discovery, verification, and writing.
- We will keep one parent Incident Collector, but give it specialist subagents.
Paste the subagent definitions above the entrypoint:
function createIncidentCollectorSubagents( config: RunConfig,): Record<string, AgentDefinition> { return { "incident-database-discovery": { description: "Find candidate GenAI, LLM, and agentic AI incidents from incident databases, public reports, vendor posts, and reputable news.", prompt: [ "You are a discovery subagent for the Incident Collector.", `Find candidate incidents matching the workshop scope.`, `Respect this recency window: ${config.recencyWindow}.`, "Return candidates with title, date, source URLs, affected system, relevance, and evidence quality.", "Do not write files. Do not invent details.", ].join("\n"), tools: [...SAFE_RESEARCH_TOOLS], model: config.subagentModel, effort: "medium", maxTurns: 10, }, "coding-agent-incident-discovery": { description: "Find incidents involving coding agents, Claude Code, MCP, autonomous tool use, AI infrastructure, and developer guardrail failures.", prompt: [ "You are a specialist discovery subagent for coding-agent and agentic development incidents.", "Search for AI coding tools, autonomous agents, MCP/tool execution, secrets exposure, supply chain failures, production damage, prompt injection, and data exfiltration.", `Respect this recency window: ${config.recencyWindow}.`, "Return candidate incidents with source URLs, observed impact, and guardrail relevance.", "Do not write files. Do not invent details.", ].join("\n"), tools: [...SAFE_RESEARCH_TOOLS], model: config.subagentModel, effort: "medium", maxTurns: 10, }, "incident-verifier": { description: "Verify candidate incidents for source validity, scope fit, duplicate overlap, dates, impact claims, and workshop usefulness.", prompt: [ "You are the verification subagent for the Incident Collector.", "Verify source URLs where possible.", "Reject weak evidence, out-of-scope incidents, and duplicates.", "Return accepted, rejected, and uncertain lists.", "Do not write files.", ].join("\n"), tools: [...SAFE_RESEARCH_TOOLS], model: config.subagentModel, effort: "high", maxTurns: 12, }, };}What this does:
- Each subagent has a job.
- Each subagent has its own prompt and tool access.
- Discovery subagents cannot write files.
- The parent owns the final artifact.
11. Add the Multi-Agent Prompt
Paste:
function buildMultiAgentPrompt(config: RunConfig): string { return [ `Collect ${config.incidentCount} high-quality incidents for the workshop using your subagents.`, "", "Required orchestration:", "1. Use the `incident-database-discovery` Agent for broad incident discovery.", "2. Use the `coding-agent-incident-discovery` Agent for coding-agent and agentic AI incidents.", "3. Merge candidate lists yourself.", "4. Use the `incident-verifier` Agent to verify sources, dates, scope fit, duplicates, and evidence quality.", "5. Write only the verified final artifacts yourself.", "", "Subagents should return candidate or verification notes only. They should not write final output files.", "", "Output files, relative to the current working directory:", "- `workshop-outputs/data/incidents.jsonl`", "- `workshop-outputs/01-incident-registry.md`", "- `workshop-outputs/collection-method.md`", ].join("\n");}What this does:
- Multi-agent systems need orchestration rules.
- We do not just create subagents and hope they coordinate.
- The parent prompt defines the sequence and final ownership.
12. Add the Multi-Agent Main Function
Paste:
async function runMultiAgent(): Promise<void> { const config = resolveRunConfig("multi"); await prepareWorkspace(config);
const options: Options = { cwd: config.workspaceDir, model: config.model, permissionMode: "acceptEdits", maxTurns: 25, persistSession: false, settingSources: [], systemPrompt: { type: "preset", preset: "claude_code", append: buildSystemAppend(config), }, tools: ["Agent", ...SAFE_RESEARCH_TOOLS, ...WRITING_TOOLS], allowedTools: ["Agent", ...SAFE_RESEARCH_TOOLS, ...WRITING_TOOLS], agents: createIncidentCollectorSubagents(config), };
await runClaudeAgent( "multi-agent-incident-collector", buildMultiAgentPrompt(config), options, );
console.info(`\nMulti-agent output: ${config.outputDir}`);}Now replace the single-agent-only entrypoint with:
const mode = process.argv.includes("--multi") ? "multi" : "single";
const run = mode === "multi" ? runMultiAgent : runSingleAgent;
run().catch((error: unknown) => { console.error(error); process.exitCode = 1;});Run single:
pnpm startRun multi:
pnpm start -- --multiInspect:
ls outputs/multi/workshop-outputsExpected:
01-incident-registry.mdcollection-method.mddata13. Checkpoint: Single vs Multi-Agent
After completing both versions, compare the code and outputs.
- We added the
Agenttool. - We added an
agentsregistry. - Subagents got their own prompts.
- Subagents got restricted tools.
- The parent kept final writing responsibility.
- The output should be more auditable because verification is explicit.
14. Why This Matters for LifeOSAI
Claude Agent SDK gives us the execution primitive. LifeOSAI turns this into an operational system.
Claude Agent SDK -> one agent loop -> streamed SDK messages -> tools -> subagents
LifeOSAI -> company agents -> assigned tasks -> issue comments -> file artifacts -> dashboards -> live events -> audit trailThe Incident Collector built here is the first agent in the larger workshop prototype.
After this, LifeOSAI can coordinate Root Cause, Threat Modeling, Guardrail Design, Policy-as-Code, Claude Hooks, Evidence, and Critic agents around the generated incident corpus.
Official references
- Claude Agent SDK — TypeScript
- Claude Agent SDK — Overview
- Claude Agent SDK — Quickstart
- Claude Agent SDK — Agent loop
Read next
- Run the nine-agent pipeline in Claude Code — The same problem worked end-to-end with ten specialist agents — no code, just terminal prompts.
- Setup · Claude Code (terminal) — Install Claude Code and authenticate against Vertex.