P115 min

What is an Agent?

Your team spends hours every week on repetitive, tool-heavy work: triaging issues, updating docs, reviewing PRs, rewriting reports. An agent is software that does that work by reasoning in a loop and calling real tools. If you can build a REST handler, you can build an agent — you'll ship one in the next five minutes.

SDK Focusquery()for await..ofmessage streamsession_id -> resume

Ship it (5 minutes)

Try ItRun this first, read second

Install the SDK, paste the code, and watch the loop. Then name the user, workflow step, and reversible outcome this agent could improve; if a script would suffice, say so.

bash
npm i @anthropic-ai/claude-agent-sdk

Then pick one auth path — the SDK accepts any of the three:

bash
# Option 1 — Claude Code subscription (Pro/Max, no per-token cost)
npm i -g @anthropic-ai/claude-code
claude /login   # one-time browser flow

# Option 2 — Direct Anthropic API key
export ANTHROPIC_API_KEY=sk-ant-...

# Option 3 — Anthropic-compatible proxy (DeepSeek shown as example; for Bedrock,
# OpenRouter, or other providers, refer to that provider's own docs)
export ANTHROPIC_AUTH_TOKEN=sk-...                        # your DeepSeek API key
export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
export ANTHROPIC_DEFAULT_SONNET_MODEL=deepseek-v4-pro[1m] # alias used in this lesson
export ANTHROPIC_DEFAULT_OPUS_MODEL=deepseek-v4-pro[1m]   # used in later lessons
export ANTHROPIC_DEFAULT_HAIKU_MODEL=deepseek-v4-flash    # used in later lessons
# Full DeepSeek var list: https://api-docs.deepseek.com/guides/coding_agents
typescript
// first-agent.ts
import { query, type SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";

const assistantText = (m: SDKAssistantMessage) =>
  m.message.content
    .filter((b): b is Extract<typeof b, { type: "text" }> => b.type === "text")
    .map((b) => b.text)
    .join("\n");

const response = query({
  prompt: "List the 5 largest files in the current directory and tell me which look stale.",
  options: {
    model: "sonnet",
    tools: ["Bash", "Read", "Glob"],
    allowedTools: ["Read", "Glob"], // auto-approve safe read-only tools
  },
});

let sessionId: string | undefined;
for await (const msg of response) {
  switch (msg.type) {
    case "system":
      sessionId = msg.session_id;
      console.log("session:", sessionId);
      break;
    case "assistant":
      console.log("assistant:", assistantText(msg));
      break;
    case "tool_use_summary":
      console.log("act:", msg.summary);
      break;
    case "result":
      if (msg.subtype === "success") console.log("done:", msg.result);
      break;
  }
}
bash
npx tsx first-agent.ts

You should see a stream like system → assistant → tool_use_summary → assistant → result. That stream is the agent loop — perceive, think, act, repeat.

piPi equivalentSame loop, push-based events; file-based provider config

If you'd rather build on Pi (open-source MIT-licensed coding agent), the same exercise looks like this. The teaching path is still Claude — this is a portability hint, not a fork in the road.

Pi 0.79.x requires Node >=22.19.0. For the CLI, install with lifecycle scripts disabled; Pi's published package does not need install scripts, and this is the documented supply-chain-safe path:

bash
node --version
npm install -g --ignore-scripts @earendil-works/pi-coding-agent

For SDK embedding in a local lesson file, add the coding-agent package and its core types:

bash
npm install @earendil-works/pi-coding-agent @earendil-works/pi-agent-core

Pi reads provider config from ~/.pi/agent/auth.json. The key field can be (a) an env var name that Pi resolves at runtime, (b) a !shell-command for lazy read, or (c) a literal string. The fastest path uses the built-in DeepSeek provider:

bash
mkdir -p ~/.pi/agent

# If DEEPSEEK_API_KEY is already exported in your shell, this is enough:
cat > ~/.pi/agent/auth.json <<'JSON'
{ "deepseek": { "type": "api_key", "key": "DEEPSEEK_API_KEY" } }
JSON

# If it's not exported (or you'd rather keep plaintext out of pi config),
# point pi at a key file via shell-command:
# echo "DEEPSEEK_API_KEY=sk-..." > ~/.deepseek && chmod 600 ~/.deepseek
# { "deepseek": { "type": "api_key",
#   "key": "!awk -F= '/^DEEPSEEK_API_KEY=/{print $2; exit}' ~/.deepseek" } }

chmod 600 ~/.pi/agent/auth.json

For self-hosted or open-weight providers (Ant-Ling Ring, Qwen, ZenMux), add them to ~/.pi/agent/models.json.

typescript
// first-agent.ts
import { createAgentSession } from "@earendil-works/pi-coding-agent";
import type { AgentMessage } from "@earendil-works/pi-agent-core";

const assistantText = (m: AgentMessage): string => {
  if (m.role !== "assistant" || !Array.isArray(m.content)) return "";
  return m.content
    .filter((b): b is { type: "text"; text: string } => b.type === "text")
    .map((b) => b.text)
    .join("");
};

const { session } = await createAgentSession({ tools: ["read", "bash"] });

// Pi exposes the session id immediately — no init event to wait for.
const sessionId = session.sessionId;
console.log("session:", sessionId);

const unsubscribe = session.subscribe((event) => {
  switch (event.type) {
    case "message_end":
      console.log("assistant:", assistantText(event.message));
      break;
    case "tool_execution_start":
      console.log("act:", event.toolName);
      break;
    case "agent_end": {
      const last = event.messages
        .map(assistantText)
        .filter((t) => t.length > 0)
        .pop();
      if (last) console.log("done:", last);
      break;
    }
  }
});

try {
  await session.prompt(
    "List the 5 largest files in the current directory and tell me which look stale."
  );
} finally {
  unsubscribe();
}

Key mappings

Provider config:

env vars only (ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL) vs a config file (~/.pi/agent/auth.json + models.json) whose key field can itself reference an env var name, run a !shell-command, or hold a literal.

Multi-provider:

single Anthropic-compatible endpoint per process vs multiple providers registered in models.json, switchable per session.

Loop shape: push (session.subscribe) vs pull (for await).
Tool exposure:

Pi tools use lowercase names (["bash", "read"]); Claude Agent SDK tools use PascalCase names (["Bash", "Read", "Glob"]), with allowedTools layered on only for pre-approval.

Read what happened

Each message type maps to a step in the canonical Perception-Action Loop:

python
while not task_complete:
    observation = perceive(environment)  # tool_use_summary
    action = think(observation, goal, memory)  # assistant
    result = act(action)  # tool_use_summary
    memory.update(observation, action, result)  # implicit in the session

query() IS the loop

You didn't implement the loop — query() runs it for you. Your job is to decide the prompt (goal), tools (action space), allowedTools (which tools are auto-approved without a prompt), and what to do with the messages (observation + side-effects). Those knobs are 80% of agent design.

If you're coming from frontend or backend

The shape is familiar: query() is an async generator, like a server-sent-events handler. tools is your route table — which sub-handlers are visible for this request. allowedTools is the pre-approval list inside that route table. session_id + resume is your session cookie for stateful multi-turn work. The new part isn't the plumbing; it's that the control flow is decided by the model, not by your code. Your job shifts from "write the branches" to "shape the action space and observe what the model does".

Extend it (10 minutes)

Do these three in order. Each takes a minute to code and shows a distinct capability.

1. Swap the job. Change the prompt to something your actual team would ask:

typescript
prompt: "Find every TODO comment in src/, group by file, and summarize the top 3 themes.";

2. Tighten the action space. Remove Bash from tools, or add it to disallowedTools. Re-run. Notice how the agent routes around the missing tool or gives up — tool selection is a security and scope decision, not a convenience.

3. Resume the session. Save the sessionId from the first run, then continue the conversation:

typescript
const followup = query({
  prompt: "For the top theme, write a one-paragraph issue description.",
  options: {
    model: "sonnet",
    resume: sessionId,
    tools: ["Read", "Glob"],
    allowedTools: ["Read", "Glob"],
  },
});
piPi equivalentReuse the session object instead of resuming an id
typescript
// Pi keeps the session object alive — no `resume: id` parameter.
// The AgentSession IS the conversation.
await session.prompt("For the top theme, write a one-paragraph issue description.");
Resume model:

stateful: reuse the AgentSession object across turns instead of passing resume: id.

Session resume continues one conversation across query() calls; it does not create long-term memory or autonomy.

Agent vs chatbot

FeatureChatbotAgent
Interaction styleUsually conversationalUsually task-oriented
Execution capabilityMay produce text or call toolsUses tools on an environment
AutonomyOften user-ledMay sequence actions within bounds
StateMay be single- or multi-turnPersistence is designed separately
Failure modeWrong answerWrong action — higher stakes

One-shot vs stateful

Chatbots and agents can both be single- or multi-turn. resume preserves SDK conversation state; agents are distinguished by goal-directed tool use and model-selected control flow.

Design check — when NOT to use an agent

Agents are expensive, non-deterministic, and can take destructive actions. Reach for them only when all three of these are true:

  • The task is tool-heavy. If the work is pure text generation with no external state, a plain prompt is cheaper and more predictable.
  • The steps are not knowable in advance. If you can write a 10-line script, write the script — a for loop beats an agent on cost, speed, and auditability.
  • The blast radius is bounded. You can revert what the agent does (git, sandboxes, staging DBs, dry-run flags), or a human approves before it touches production.

The production gap

This lesson ran a happy-path loop. In production you'll handle partial failures, rate limits, token budgets, and non-deterministic re-planning. P6 (Patterns) and P12 (Production) close that gap — don't ship this code as-is.

At work

Rolling it out at your company. Even this 30-line script is enough for a credible pilot:

  • Start with one painful, reversible workflow. PR summarization, doc freshness checks, log triage. Avoid anything that writes to customer data on the first pilot.
  • Measure wall-clock time-saved, not "AI adoption". Before/after on five real instances beats any demo.
  • Show the action log. Treat tool_use_summary as readable telemetry, not a complete audit record. Persist protected, actor-linked tool events or hook records, then use them to explain what the agent actually did.
  • Keep a human approving writes for the first month. You can relax this later; you can't un-break trust after an incident.

Transitioning into an agent engineering role. What the job actually looks like:

  • You'll spend more time on the action space (which tools, what scopes, which sandbox) than on prompts. Frontend devs: think of it as designing a minimal component API. Backend devs: think route-table + authorization middleware.
  • Interviewers probe on session state, tool design, failure handling, and cost — not model trivia. If you can explain why you chose resume over re-sending context, you're ahead of most candidates.
  • A portfolio-worthy demo is small and honest: one real workflow, a measurable before/after, and a published action log. A 30-line script solving one real problem beats a sprawling multi-agent toy.

Next up

Gate: P1 Complete — You can run query(), capture session_id, resume a session, explain the agent loop to someone coming from a web background, and name one workflow (at your job or in your portfolio) that's a credible candidate for an agent pilot.

Sign in to save lesson progress, unlock flashcards, and continue where you left off.