# Workshop 1 of 3 — Build with the Claude Agent SDK

**What you build:** A working AI Incident Collector in TypeScript — first as a single-agent system, then converted to multi-agent with SDK subagents.
**Time:** ~90 minutes including setup.
**Prerequisites:**

- Node.js 20 or later
- TypeScript familiarity (basic — types, imports, async)
- Anthropic API access (direct key, or via GCP Vertex / AWS Bedrock)
- ~200 MB free disk space for `node_modules`

**Public workshop repo:** [github.com/metaweavehq/ai-guardrail-lab](https://github.com/metaweavehq/ai-guardrail-lab) — clone-ready, contains the 10-agent instruction bundles used in Workshop 2.
**Next:** Workshop 2 of 3 — Run with Claude Code Terminal (same nine-agent pipeline, no TypeScript required).

---


This hands-on builds a working AI Incident Collector with the Claude Agent SDK.

You will first create a single-agent system, then convert it into a multi-agent system using SDK subagents.

## What We Will Build

We will build an **AI Incident Collector**.

First version:

```text
Single Incident Collector Agent
  -> searches for AI incidents
  -> verifies evidence
  -> writes incidents.jsonl
  -> writes 01-incident-registry.md
```

Second version:

```text
Incident Collector Parent Agent
  -> uses Agent subagents for discovery and verification
  -> merges results
  -> writes the final artifacts
```

## 1. Create the Project

Create a new folder:

```bash
mkdir claude-agent-sdk-training
cd claude-agent-sdk-training
```

Create `package.json`:

```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:

```bash
pnpm install
```

Create `tsconfig.json`:

```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:

```bash
mkdir -p src
```

## 2. Import the SDK

Create `src/index.ts`.

Start with the SDK imports:

```ts
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 the `Agent` tool.

## 3. Add Runtime Configuration

Paste this below the imports:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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 await` receives 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:

```ts
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:

```ts
runSingleAgent().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});
```

Run:

```bash
pnpm start
```

Inspect:

```bash
ls outputs/single/workshop-outputs
```

Expected:

```text
01-incident-registry.md
data
```

## 10. 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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```bash
pnpm start
```

Run multi:

```bash
pnpm start -- --multi
```

Inspect:

```bash
ls outputs/multi/workshop-outputs
```

Expected:

```text
01-incident-registry.md
collection-method.md
data
```

## 13. Checkpoint: Single vs Multi-Agent

After completing both versions, compare the code and outputs.

- We added the `Agent` tool.
- We added an `agents` registry.
- 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.

```text
Claude Agent SDK
  -> one agent loop
  -> streamed SDK messages
  -> tools
  -> subagents

LifeOSAI
  -> company agents
  -> assigned tasks
  -> issue comments
  -> file artifacts
  -> dashboards
  -> live events
  -> audit trail
```

The 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

- https://code.claude.com/docs/en/agent-sdk/typescript
- https://code.claude.com/docs/en/agent-sdk/overview
- https://code.claude.com/docs/en/agent-sdk/quickstart
- https://code.claude.com/docs/en/agent-sdk/agent-loop
- https://code.claude.com/docs/en/agent-sdk/claude-code-features

---

## Next workshop

Workshop 2 of 3 — **Run with Claude Code Terminal**: orchestrate the 9-agent pipeline by pasting prompts into Claude Code. Same outcomes as the multi-agent SDK build above, but without writing TypeScript. The agent instruction files live at [github.com/metaweavehq/ai-guardrail-lab](https://github.com/metaweavehq/ai-guardrail-lab).
