Build your own Claude Code
The agent loop·18 min

One turn

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage one-turn --watch

Every agent is a loop, and every loop starts with one turn: hand the model the transcript, get back a message, read what it wants to do next. Before you can loop, you have to take that single step cleanly — call the model, append its reply to the running messages, and surface the two things the loop will branch on: the stop_reason and any tool_use blocks the model emitted.

No real API yet. The model is injected — at grade time it's a cassette that replays a recorded /v1/messages response — so this step is pure plumbing: in goes the transcript, out comes a well-formed assistant message.

one turn in, assistant message out
messages  →  model(messages)  →  { content: [...blocks], stop_reason }
                                       │
                       append to messages, then read:
                       stop_reason  ("tool_use" | "end_turn" | ...)
                       tool_use blocks  ({ id, name, input })

What to build

Your task

Take a single model step

In src/loop.ts, implement one model step. Call the injected model with the current messages, append the assistant message it returns to that array, and report back its stop_reason together with the names of any tool_use blocks in the response. That stop_reason is the signal the loop will key on next step; the tool_use blocks are the work it will dispatch.

Graded by program.ts one-turn, which builds a cassette model from the fixture, runs your step once, and prints { stopReason, toolNames }.

src/loop.ts
import type { Message, Model, AssistantMessage } from './types.ts'

// Take a single step: call the model, append its reply to `messages`,
// and return the stop_reason plus the tool_use blocks it emitted.
export async function step(messages: Message[], model: Model) {
  // const resp = await model(messages)
  // messages.push({ role: 'assistant', content: resp.content })
  // -> return { stopReason: resp.stop_reason, toolUses: [...] }
}
The proxy *is* the API at grade time

The injected model has the same shape as the real /v1/messages endpoint — a request of messages in, an assistant message (content blocks + stop_reason) out. In your own runs you can point it at the SDK or plain fetch against ANTHROPIC_BASE_URL; the grader swaps in a cassette that replays a recorded response. Either way your step code is identical: it never inspects what the model is, only the message it gets back.

Append the assistant message before you return

It's tempting to read stop_reason and bail without touching messages. Don't. The transcript has to carry the model's own turn — including its tool_use blocks — or the next request will be missing the context it's about to respond to. Append first, then return.

How it's graded

Process mode: your_program.sh one-turn runs program.ts one-turn on the case fixture. It feeds your step a one-turn cassette, then prints { stopReason, toolNames } as JSON. The grader compares that line to fixtures/expected.json — {"stopReason":"tool_use","toolNames":["Read"]} — as an exact match. If you forgot to read the tool_use block, toolNames comes back empty and the diff is obvious.