AI-Assisted Dev NotesRohit’s interview study guide
Chapter 01

AI-Assisted Development Today

What coding assistants actually do (completion, chat, agents; in the editor, the terminal and the cloud), how they work just enough to use them well (tokens, context windows, sampling, why they invent APIs), where the evidence says they help and hurt, and why the developer still owns every line.

4 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What AI coding assistants do

Added

An AI coding assistant is a large language model (LLM) wrapped in a tool that decides what the model gets to see and what the model's output is allowed to do. The model on its own only turns text into more text. Everything that makes it useful for code (reading your files, suggesting a line as you type, editing three files and running the tests) is done by the tool around it, which this page calls the harness: the editor extension, the terminal program or the cloud runner.

Assistants come in three shapes, and most products now offer all three:

  • Completion: as you type, the harness sends the code around your cursor to the model and shows its continuation as grey "ghost text". You accept it with Tab or ignore it. One suggestion, a few hundred milliseconds, no conversation.
  • Chat: you ask a question or give an instruction in a side panel ("explain this function", "write a test for this"), the harness attaches context (the selected code, the open file, files you mention) and the model answers in prose and code blocks. You copy or apply what you want.
  • Agents: you give a goal ("make the failing cart test pass"), and the model works through it in a loop of tool calls: read a file, search, edit, run the tests, read the output, edit again, until it decides it's finished. The harness runs each tool call, often after asking you for permission.

A useful mental model is three levels of delegation. Completion is a fast typist reading over your shoulder: it finishes your sentence, and you see every word before it lands. Chat is a well-read colleague you paste things to: it only knows what you show it, and you decide what to do with its answer. An agent is a contractor you hand a ticket and a sandbox: it does many steps you don't watch, and it comes back with a diff. The more you delegate, the more your job shifts from typing to specifying the task and checking the result (see The developer's job with an assistant).

They also differ in where they run:

WhereWhat it looks likeExamples (from each vendor's docs, checked 2026-10-27)
Editorcompletions, a chat panel, an agent that edits your working copy while you watchGitHub Copilot in VS Code, JetBrains and other IDEs; Cursor; the Claude Code extensions for VS Code and JetBrains
Terminalan agent you start in a project folder; it reads files, edits them and runs shell commandsClaude Code's CLI; GitHub Copilot CLI (generally available since 2026-02-25, per GitHub's changelog)
Cloudan agent that runs on the vendor's machines against your repository and comes back with a branch or a pull requestCopilot cloud agent (renamed from "Copilot coding agent", GitHub changelog 2026-04-01); Claude Code on the web

GitHub's own overview ("What is GitHub Copilot?", docs.github.com, checked 2026-10-27) groups Copilot's features the same way: assistive features where Copilot "responds while you work" and "you review and apply each suggestion", and agentic ones where "you describe a goal, and Copilot can work through multiple steps". The GitHub Copilot chapter covers Copilot's features one by one.

You give the task approve writes review the diff Harness editor, CLI or cloud runner Context instructions + task + every tool call + every result Permission rules reads: allowed writes, shell: ask you Model (LLM) returns a tool call or a final answer stateless per request Tools read_file edit_file run_tests MCP servers task ask all of it call run result
An agent is a loop run by the harness: the model only ever proposes the next step; the harness decides whether it runs, and appends the result to the context.

Here is that loop as a runnable model. The "model" is a fixed rule that reads the context, standing in for an LLM so the run is repeatable; everything else (tools, a permission gate, a context that grows) is how real agents are structured.

agent-loop.mjs
// A coding agent is a loop: the model proposes one tool call, the harness runs it, and the result is appended
// to the context for the next turn. `decide` is a fixed rule standing in for the LLM, so the run is repeatable.
const files = new Map([
  ["src/cart.js", "export const total = (items) => items.reduce((sum, i) => sum + i.price, 0);"],
  ["test/cart.test.js", "total([{ price: 2, qty: 3 }, { price: 5, qty: 1 }]) === 11"],
]);

const tools = {
  read_file: ({ path }) => files.get(path) ?? `error: no such file ${path}`,
  edit_file: ({ path, content }) => (files.set(path, content), `wrote ${content.length} chars to ${path}`),
  run_tests: async () => {
    const src = files.get("src/cart.js");
    const { total } = await import("data:text/javascript," + encodeURIComponent(src));
    const got = total([{ price: 2, qty: 3 }, { price: 5, qty: 1 }]);
    return got === 11 ? "1 passed" : `1 failed: expected 11, got ${got}`;
  },
};
const needsApproval = new Set(["edit_file"]); // reads and tests run freely; writes ask the human
const approve = (call) => call.args.path.startsWith("src/"); // the human's rule for this session

// Stand-in for the model: it only sees the context (the task plus every earlier result).
function decide(context) {
  const results = context.filter((m) => m.role === "tool").map((m) => m.text);
  if (results.length === 0) return { tool: "read_file", args: { path: "test/cart.test.js" } };
  if (results.length === 1) return { tool: "read_file", args: { path: "src/cart.js" } };
  const last = results.at(-1);
  if (last === "denied by user") return { answer: "Not changed: the edit to src/cart.js was denied. Proposed fix: price * qty." };
  if (last.startsWith("wrote") || results.length === 2) return { tool: "run_tests", args: {} };
  if (last.includes("failed")) {
    const content = "export const total = (items) => items.reduce((sum, i) => sum + i.price * i.qty, 0);";
    return { tool: "edit_file", args: { path: "src/cart.js", content } };
  }
  return { answer: `Fixed: total now multiplies price by qty. Evidence: ${last}.` };
}

const context = [{ role: "user", text: "The cart test fails. Fix it." }];
for (let turn = 1; turn <= 8; turn++) {
  const step = decide(context);
  if (step.answer) { console.log(`turn ${turn}  answer: ${step.answer}`); break; }
  const call = { tool: step.tool, args: step.args };
  let result;
  if (needsApproval.has(call.tool) && !approve(call)) result = "denied by user";
  else result = await tools[call.tool](call.args);
  const gate = !needsApproval.has(call.tool) ? "" : result === "denied by user" ? " [denied]" : " [approved]";
  console.log(`turn ${turn}  ${call.tool}(${call.args.path ?? ""})${gate} → ${result.split("\n")[0].slice(0, 60)}`);
  context.push({ role: "assistant", text: JSON.stringify(call) }, { role: "tool", text: result });
}
const chars = context.reduce((n, m) => n + m.text.length, 0);
console.log(`context: ${context.length} messages, ${chars} characters, all re-sent to the model every turn`);
Result
turn 1  read_file(test/cart.test.js) → total([{ price: 2, qty: 3 }, { price: 5, qty: 1 }]) === 11
turn 2  read_file(src/cart.js) → export const total = (items) => items.reduce((sum, i) => sum
turn 3  run_tests() → 1 failed: expected 11, got 7
turn 4  edit_file(src/cart.js) [approved] → wrote 83 chars to src/cart.js
turn 5  run_tests() → 1 passed
turn 6  answer: Fixed: total now multiplies price by qty. Evidence: 1 passed.
context: 11 messages, 538 characters, all re-sent to the model every turn
What’s happening
  1. The context starts with one message, the task. On turn 1, decide sees no tool results yet and asks to read the test; the harness runs read_file (reads need no approval) and appends both the call and its result to context. Turn 2 reads the source the same way.
  2. Turn 3: with two results in context, decide asks for run_tests. The harness imports the current src/cart.js and runs the check: 2 + 5 = 7, not 11, so the result is 1 failed: expected 11, got 7. That text is now in the context, which is the only way the "model" learns anything happened.
  3. Turn 4: the last result says failed, so decide proposes edit_file with price * qty. edit_file is in needsApproval, so the harness asks approve (the human's rule for this session: edits under src/ are fine) before running it. The model never touches the file itself; it only produces a request.
  4. Turn 5 runs the tests again: 2 × 3 + 5 × 1 = 11, 1 passed. Turn 6: the last result isn't a failure, so decide returns an answer instead of a tool call, and the loop ends. The answer cites the evidence (1 passed) rather than just claiming success.
  5. The last line is the cost of agency: 11 messages and 538 characters, and the whole context is sent again on every turn because the model keeps no state between requests (Anthropic's context-window docs, checked 2026-10-27: each turn's input "contains all previous conversation history plus the current user message"). Real sessions hold whole files and test logs, which is why agents fill context windows quickly.
  6. If you changed approve to allow only docs/, turn 4 prints edit_file(src/cart.js) [denied] → denied by user, and on turn 5 the stand-in answers Not changed: the edit to src/cart.js was denied. Proposed fix: price * qty. A denial is just another tool result: the model has to cope with it, and a well-built agent reports what it couldn't do instead of claiming success.
AdvancedEditor, terminal and cloud agents

The loop is the same everywhere; what changes is what the tools can reach and who's watching:

  • Editor agents (Copilot agent mode, Cursor's agent, the Claude Code IDE extensions) edit your working copy while you watch the diff appear. Tools are the editor's own (read, search, edit, run a terminal command), plus MCP servers you configure. You're usually present, so approvals are cheap.
  • Terminal agents (Claude Code, Copilot CLI) have the shell: they can run your build, your test runner, git, curl. That makes them powerful for "make it pass" loops and dangerous without permission rules, which is why both products document approval modes. GitHub's changelog for Copilot CLI (2026-02-25) describes a plan mode in which Copilot "asks clarifying questions, and builds a structured implementation plan before writing any code", and an autopilot mode that keeps "iterating without stopping for approval".
  • Cloud agents (Copilot cloud agent, Claude Code on the web) run on the vendor's machines in a fresh checkout, so you aren't watching. Their output is a branch or a pull request, and review becomes the only checkpoint. GitHub's changelog entry of 2026-04-01 ("Research, plan, and code with Copilot cloud agent", the one that announced the new name) says the cloud agent can now work on a branch without opening a pull request, or produce a plan for you to review before it writes code.

The choice is mostly about feedback: work you want to steer turn by turn belongs in the editor; a well-specified task with a test that defines "done" can go to a terminal or cloud agent.

AdvancedWhat a completion request contains

Completions look like magic because the request is invisible. GitHub described what goes into Copilot's completion prompts in "How GitHub Copilot is getting better at understanding your code" (Johan Rosenkilde, GitHub blog, May 2023, updated February 2024): prompts are "compilations of IDE code and relevant context", built from the code before the cursor (the prefix), the code after it (the suffix, which "fill-in-the-middle" training lets the model use), and snippets from other open files ("neighboring tabs"), chosen to fit a small budget; at the time its fast models could process "about 6,000 characters at a time". This model assembles a request the same way. It's the idea from that article, not Copilot's actual algorithm, and today's limits are different.

fim-prompt.mjs
// How a completion request can be assembled: the code before the cursor (prefix), the code after it (suffix),
// and snippets from other open files that share identifiers with the code near the cursor. This follows the
// ideas in GitHub's 2023 write-up on Copilot's prompts; it isn't Copilot's actual algorithm.
const editing = `import { Room } from "./types";
export function freeRooms(rooms: Room[], at: Date): Room[] {
  return rooms.filter((r) => ▮);
}
export function roomLabel(r: Room) { return r.name + " (" + r.capacity + ")"; }`;

const openTabs = {
  "types.ts": "export interface Room { id: string; name: string; capacity: number; bookings: Booking[] }\nexport interface Booking { start: Date; end: Date }",
  "booking.ts": "export function overlaps(b: Booking, at: Date) { return b.start <= at && at < b.end; }",
  "theme.ts": "export const colors = { primary: '#84cc16', surface: '#0f1a04' };",
};

const BUDGET = 6_000; // characters; the 2023 article says its fast models handled "about 6,000 characters"
const KEYWORDS = new Set(["import", "export", "from", "function", "return", "const", "interface", "string", "number"]);
const words = (s) => new Set(s.match(/[A-Za-z_]\w+/g).filter((w) => !KEYWORDS.has(w)));
const [prefix, suffix] = editing.split("▮");
const nearCursor = words(prefix.slice(-200) + suffix.slice(0, 50));

const scored = Object.entries(openTabs).map(([file, text]) => {
  const w = words(text);
  const shared = [...w].filter((x) => nearCursor.has(x));
  return { file, text, shared, score: shared.length / new Set([...w, ...nearCursor]).size };
}).sort((a, b) => b.score - a.score);

let prompt = "";
for (const s of scored) {
  const keep = s.score > 0.05; // a deliberately low bar, as the article reports worked best
  console.log(`${s.file.padEnd(11)} score ${s.score.toFixed(2)} shared [${s.shared.join(", ")}] ${keep ? "→ included" : "→ skipped"}`);
  if (keep) prompt += `// from ${s.file}\n${s.text.replace(/^/gm, "// ")}\n`;
}
prompt = `${prompt}<PRE>${prefix}<SUF>${suffix}<MID>`;
console.log(`prompt: ${prompt.length} of ${BUDGET} characters; the model generates the missing middle after <MID>`);
console.log(prompt);
Result
booking.ts  score 0.17 shared [at, Date] → included
types.ts    score 0.13 shared [Room, Date] → included
theme.ts    score 0.00 shared [] → skipped
prompt: 495 of 6000 characters; the model generates the missing middle after <MID>
// from booking.ts
// export function overlaps(b: Booking, at: Date) { return b.start <= at && at < b.end; }
// from types.ts
// export interface Room { id: string; name: string; capacity: number; bookings: Booking[] }
// export interface Booking { start: Date; end: Date }
<PRE>import { Room } from "./types";
export function freeRooms(rooms: Room[], at: Date): Room[] {
  return rooms.filter((r) => <SUF>);
}
export function roomLabel(r: Room) { return r.name + " (" + r.capacity + ")"; }<MID>
What’s happening
  1. The cursor ▮ splits the file into a prefix (the import, the signature, rooms.filter((r) => ) and a suffix ();, the closing brace and roomLabel). The suffix matters: it tells the model the expression must end before );, so it shouldn't write a whole new function.
  2. nearCursor is the set of identifiers close to the cursor (Room, rooms, filter, at, Date…), ignoring keywords. Each open tab is scored by how many identifiers it shares with that set, relative to all identifiers involved (Jaccard similarity).
  3. booking.ts shares at and Date (0.17) and types.ts shares Room and Date (0.13), so both go in, as comments above the code. theme.ts shares nothing and is skipped. The bar is deliberately low; the article reports that "setting a very low bar for when to include a match" gave the best suggestions.
  4. The assembled prompt is 495 characters: the two snippets, then <PRE> prefix <SUF> suffix <MID>. The model generates what belongs at the cursor. With overlaps and Booking in view, a plausible completion is something like !r.bookings.some((b) => overlaps(b, at)); without them, the model would have to guess that a room has bookings at all.
  5. That's the practical lesson for completions: the files you have open are part of the prompt. Opening the types file and the helper you want used is the cheapest context you can give a completion engine. (The sentinel markers differ between models; <PRE>, <SUF> and <MID> here only show the structure.)
AdvancedWhat the harness adds

The model is the same few model families across many products. The harness is where products differ, and it's what you configure as a team:

Harness featureWhat it doesWhere you'll meet it
Context assemblychooses which files, symbols and history go into each request@file mentions, "open tabs" context, repository indexing
Instructionsa file of standing rules prepended to every request.github/copilot-instructions.md, CLAUDE.md, AGENTS.md
Toolswhat the model can ask to dobuilt-in read/edit/run tools, plus MCP servers
Permissionswhich tool calls run without askingapproval prompts, allow/deny lists, sandboxed cloud runners
Compactionsummarises old turns when the context fillsautomatic in long sessions

That's also why "which model is best" is the wrong first question for a team: the same model with better context, better instructions and a test it can run gives better results. The Prompting for Code chapter is about the context half of that.

AdvancedAn agent's permissions are your permissions
Which mode would you use?

For each task, pick completion, chat or an agent, and say why: (a) writing the fifth near-identical Express route handler in a file that already has four; (b) understanding what a 300-line legacy function does; (c) "every endpoint under /api/v1/rooms must return the new error shape; the contract tests define it".

Show answer

(a) completion, (b) chat, (c) an agent, ideally one that can run the contract tests.

What’s happening
  1. (a) The pattern is right there in the file: four handlers above the cursor. Completion is built for exactly this: it continues the local pattern, and you see each suggested line before it lands. Chat would be slower, and an agent is overkill for one function.
  2. (b) You want an explanation, not a change. Chat with the function selected gives the model the code and lets you ask follow-ups ("what happens when retries is 0?"). Treat the explanation as a hypothesis and check the claims that matter against the code or a quick test.
  3. (c) Many files, a clear definition of done (the contract tests), and mechanical edits: that's an agent's job. The tests turn "done" into something the loop can check by itself, so it can iterate without you watching every step.
  4. The general rule: the less you'll watch, the more the task needs a check the agent can run. Without one, an agent's "done" means only "looks done to the model".

#How they work, just enough to use them well

Added

You don't need the maths of transformers to use an assistant well, but you do need four facts, because each one explains a behaviour you'll see every day.

  1. The model predicts the next token, over and over. A token is a chunk of text: a common word, part of a word, a symbol, some whitespace. The model takes the token sequence so far and produces a probability for every token in its vocabulary; the harness picks one (that's sampling), appends it and asks again. A 40-line function is a few hundred of these steps. OpenAI's help article on tokens gives the rule of thumb that one token is about four characters, or about three-quarters of a word, of English, and says plainly that it varies by model and encoding.
  2. It sees only its context window. The context window is everything the model can look at in one request: system prompt, instructions, tool definitions, the files and history the harness included, and the answer it's writing. Anthropic's docs (checked 2026-10-27) define it as "all the text a language model can reference when generating a response, including the response itself" and call it the model's "working memory". Nothing outside it exists for that request, and there's no memory between requests: chat history works because the harness sends it again every time.
  3. Its knowledge is frozen at training time. What the model "knows" about libraries comes from text it was trained on, up to a cutoff date, mixing every version of every library it saw. Anything newer, or anything private (your codebase, your team's conventions, the API of the device you ship), it knows only if it's in the context.
  4. Nothing in generation checks facts. The model scores how plausible a continuation is, not whether it's true. A method name that would be plausible in this position gets probability whether or not it exists in the version you have installed.

The mental model: a very well-read autocomplete with a small desk. It once read an enormous library (training) and remembers the patterns, not the pages. For any one task it can only work with what's on its desk (the context window). When something it needs isn't on the desk, it doesn't say "I don't have that page"; it reconstructs it from the patterns, fluently, and sometimes wrongly. Almost every practical rule in this course follows from that picture: put the right things on the desk, keep junk off it, and check anything it reconstructed from memory.

Here's fact 1 and fact 4 together, at the moment a completion is generated after fs.. The scores are made up to show the mechanism; a real model scores its whole vocabulary with learned weights.

next-token.mjs
// What a completion model does at `fs.` : score every candidate next token, then pick one.
// The scores below are made up to show the mechanism; a real model scores its whole vocabulary
// (tens of thousands of tokens or more) with learned weights.
import * as fs from "node:fs";

const prompt = 'import fs from "node:fs";\nconst config = JSON.parse(fs.';
const logits = { readFileSync: 3.1, readFile: 2.3, readJson: 1.9, readJsonSync: 1.2, existsSync: 0.9, open: 0.2 };

function softmax(scores, temperature) {
  const exps = Object.entries(scores).map(([tok, s]) => [tok, Math.exp(s / temperature)]);
  const sum = exps.reduce((n, [, e]) => n + e, 0);
  return exps.map(([tok, e]) => [tok, e / sum]);
}

function mulberry32(seed) { // small seeded PRNG so the "sampling" is reproducible
  return () => {
    seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function sample(probs, rand) {
  let r = rand();
  for (const [tok, p] of probs) if ((r -= p) < 0) return tok;
  return probs.at(-1)[0];
}

console.log(prompt.split("\n")[1] + "▮");
const exists = (tok) => (typeof fs[tok] === "function" ? "exists in node:fs" : "NOT in node:fs");
for (const t of [0.5, 1.0, 1.5]) {
  const probs = softmax(logits, t);
  const row = probs.map(([tok, p]) => `${tok} ${(p * 100).toFixed(0)}%`).join("  ");
  const bad = probs.filter(([tok]) => !(tok in fs)).reduce((n, [, p]) => n + p, 0);
  console.log(`T=${t.toFixed(1)}  ${row}  → nonexistent: ${(bad * 100).toFixed(1)}%`);
}

const rand = mulberry32(42);
const counts = {};
const probs = softmax(logits, 1.0);
for (let i = 0; i < 1000; i++) { const tok = sample(probs, rand); counts[tok] = (counts[tok] ?? 0) + 1; }
console.log("1000 seeded samples at T=1.0:");
for (const [tok, n] of Object.entries(counts).sort((a, b) => b[1] - a[1])) console.log(`  ${tok.padEnd(13)} ${String(n).padStart(4)}  ${exists(tok)}`);
Result
const config = JSON.parse(fs.▮
T=0.5  readFileSync 75%  readFile 15%  readJson 7%  readJsonSync 2%  existsSync 1%  open 0%  → nonexistent: 8.5%
T=1.0  readFileSync 48%  readFile 22%  readJson 15%  readJsonSync 7%  existsSync 5%  open 3%  → nonexistent: 21.8%
T=1.5  readFileSync 37%  readFile 22%  readJson 17%  readJsonSync 10%  existsSync 9%  open 5%  → nonexistent: 27.1%
1000 seeded samples at T=1.0:
  readFileSync   460  exists in node:fs
  readFile       248  exists in node:fs
  readJson       131  NOT in node:fs
  readJsonSync    83  NOT in node:fs
  existsSync      47  exists in node:fs
  open            31  exists in node:fs
What’s happening
  1. The prompt ends at JSON.parse(fs., the cursor. The model's output for this position is a score (a logit) per candidate; softmax turns scores into probabilities that sum to 1: exp(score / T) for each, divided by the total. T is the temperature.
  2. At T=1.0 the most likely candidate, readFileSync, gets 48%. But readJson (15%) and readJsonSync (7%) are not functions in node:fs: they're from the popular fs-extra package, which appears in a lot of code next to fs. calls. To the model they're plausible here, and that's all a score measures. Together they hold 21.8% of the probability.
  3. Temperature reshapes the same scores. At T=0.5 the top choice grows to 75% and the nonexistent names shrink to 8.5%; at T=1.5 the distribution flattens and they rise to 27.1%. Lower temperature means less variety, not more knowledge: if the top-scoring name were the wrong one, T=0 would pick it every time.
  4. The 1000 samples (seeded with mulberry32, so the run is reproducible) land close to the probabilities: readFileSync 460 times, the two nonexistent names 214 times between them. Each sample is one possible completion a user might see, which is why the same prompt gives different code on different tries.
  5. The last column is the one check generation never does: typeof fs[tok] === "function" against the real, installed node:fs. Even the existing names aren't all right: fs.readFile is asynchronous and takes a callback, so JSON.parse(fs.readFile(...)) is a bug, and existsSync returns a boolean. Plausible, existing and correct are three different things.
  6. This is the whole explanation of hallucinated APIs: the model completes from patterns across many libraries and versions, and nothing in the loop asks the installed package. The fix is to put a check in the loop: a type checker, a test run, or the real docs in the context (see Spotting hallucinated APIs and subtle bugs).
AdvancedWhat a chat or agent request looks like

Underneath every chat panel and agent is an API request that contains the whole conversation. This is the shape of a request to Anthropic's Messages API at the agent's third turn, written from the API reference (checked 2026-10-27); it wasn't sent anywhere. Other vendors' APIs differ in field names, not in the idea.

Messages API request (turn 3 of an agent loop)
{
  "model": "claude-sonnet-5-5",
  "max_tokens": 4000,
  "system": "You are a coding agent. Repository instructions: run `npm test` to check your work…",
  "tools": [
    { "name": "read_file", "description": "Read a file in the repository",
      "input_schema": { "type": "object", "properties": { "path": { "type": "string" } }, "required": ["path"] } },
    { "name": "run_tests", "description": "Run the test suite and return its output",
      "input_schema": { "type": "object", "properties": {} } }
  ],
  "messages": [
    { "role": "user", "content": "The cart test fails. Fix it." },
    { "role": "assistant", "content": [
      { "type": "tool_use", "id": "toolu_01", "name": "read_file", "input": { "path": "src/cart.js" } } ] },
    { "role": "user", "content": [
      { "type": "tool_result", "tool_use_id": "toolu_01",
        "content": "export const total = (items) => items.reduce((sum, i) => sum + i.price, 0);" } ] }
  ]
}
What’s happening
  1. system holds the standing instructions: the harness's own prompt plus your repository instructions file. You never see it in the chat panel, but it's in every request and counts against the window.
  2. tools describes each tool with a name, a description and a JSON Schema for its input. The model can only call what's listed, and it decides when to call a tool from the description, which is why tool descriptions matter so much when you build MCP servers (see Designing good tools).
  3. messages is the conversation so far, resent in full: the task, the model's earlier tool_use block, and the harness's tool_result with the file's contents. The model keeps no memory between requests; this array is its memory.
  4. max_tokens caps the answer, and it shares the context window with everything above it. Anthropic's docs say everything in the request counts toward the window: "the system prompt, every message in messages (including tool results, images, and documents), and your tool definitions".
  5. The response is either text or another tool_use block (with stop_reason "tool_use"); the harness runs the tool, appends the result as a new user message and sends the longer array again. That's the loop from the agent model above, on the wire.
AdvancedTokens, in practice

Three things about tokens matter day to day:

  • Limits and prices are in tokens, not characters or lines. Context window sizes, output limits and API bills are all counted in tokens. The "about four characters" rule is fine for estimates; for exact numbers use the vendor's counter (Anthropic's docs point to a token-counting API; OpenAI's to its Tokenizer tool and the tiktoken library).
  • Code and non-English text tokenise differently from English prose. Identifiers split into pieces (readFileSync is likely several tokens), long runs of punctuation and minified code are expensive, and many languages other than English use more tokens per word. Treat the rule of thumb as a rule of thumb.
  • A name is generated piece by piece. Because a long identifier is several tokens chosen one after another, a model can start a real name and finish it with a plausible but nonexistent suffix (readJson + Sync). That's one more reason invented names look so convincing: every piece was individually likely.
AdvancedBudgeting a context window

Context windows are large now (Anthropic's docs list 1M tokens for its current models and 200k for some older ones, checked 2026-10-27), but agents fill them fast, because whole files, tool output and every previous turn ride along. The budget is shared by everything, including the answer, and the harness has to decide what goes in. This model packs one request by priority with the characters-÷-4 estimate, no tokenizer needed. The window is deliberately small (32k) so the numbers are readable.

context-budget.mjs
// Tokenizer-free context budgeting: estimate tokens as characters / 4 (a rule of thumb for English and code;
// real tokenizers differ, so use the vendor's token counter when it matters) and pack by priority.
const WINDOW = 32_000; // a small window keeps the numbers readable; current models offer 128k to 1M
const RESERVED_FOR_OUTPUT = 4_000;
const est = (chars) => Math.ceil(chars / 4);

function pack(items) {
  let used = 0;
  const budget = WINDOW - RESERVED_FOR_OUTPUT;
  const rows = [];
  for (const item of [...items].sort((a, b) => a.priority - b.priority)) {
    const tokens = est(item.chars);
    const fits = used + tokens <= budget;
    if (fits) used += tokens;
    rows.push(`${fits ? "  in " : "  OUT"} ${item.name.padEnd(34)} ${String(tokens).padStart(6)}`);
  }
  rows.push(`  used ${used} of ${budget} tokens (${WINDOW} window - ${RESERVED_FOR_OUTPUT} reserved for the answer)`);
  return rows.join("\n");
}

const base = [
  { name: "system prompt + tool definitions", chars: 14_000, priority: 0 },
  { name: "repo instructions file", chars: 3_200, priority: 1 },
  { name: "the task (your message)", chars: 600, priority: 2 },
  { name: "file being edited: billing.ts", chars: 18_000, priority: 3 },
  { name: "types it imports: invoice.ts", chars: 5_600, priority: 5 },
  { name: "its test: billing.test.ts", chars: 9_000, priority: 6 },
  { name: "older chat turns", chars: 22_000, priority: 7 },
];
const bigLog = { name: "pasted CI log (whole file)", chars: 72_000, priority: 4 };
const smallLog = { name: "pasted CI log (failing test)", chars: 1_400, priority: 4 };

console.log("A: the whole CI log pasted in");
console.log(pack([...base, bigLog]));
console.log("B: only the failing test's output pasted");
console.log(pack([...base, smallLog]));
Result
A: the whole CI log pasted in
  in  system prompt + tool definitions     3500
  in  repo instructions file                800
  in  the task (your message)               150
  in  file being edited: billing.ts        4500
  in  pasted CI log (whole file)          18000
  OUT types it imports: invoice.ts         1400
  OUT its test: billing.test.ts            2250
  OUT older chat turns                     5500
  used 26950 of 28000 tokens (32000 window - 4000 reserved for the answer)
B: only the failing test's output pasted
  in  system prompt + tool definitions     3500
  in  repo instructions file                800
  in  the task (your message)               150
  in  file being edited: billing.ts        4500
  in  pasted CI log (failing test)          350
  in  types it imports: invoice.ts         1400
  in  its test: billing.test.ts            2250
  in  older chat turns                     5500
  used 18450 of 28000 tokens (32000 window - 4000 reserved for the answer)
What’s happening
  1. pack sorts the items by priority (0 first) and adds each one if it still fits in WINDOW - RESERVED_FOR_OUTPUT = 28,000 tokens. The 4,000 reserved tokens are for the answer: the response counts against the window too, so a harness that fills the window with input leaves the model no room to write the fix.
  2. The fixed costs come first: 3,500 tokens of system prompt and tool definitions you never see, 800 of repository instructions, 150 for your actual message. Then the file being edited, 4,500. That's already 8,950 tokens before any evidence.
  3. Scenario A: the whole CI log is pasted in at priority 4, 72,000 characters ≈ 18,000 tokens. It fits (26,950 used), and then nothing else does: the imported types (invoice.ts), the test file and the older chat turns are all OUT. The model will now write code against types it can't see, which is exactly when it starts inventing field names.
  4. Scenario B pastes only the failing test's output, 350 tokens. Everything fits with 9,550 tokens to spare: the model sees the types, the test and the earlier conversation. Same task, same model, and a much better chance of a correct answer, purely from what's on the desk.
  5. Real harnesses don't use this exact rule: some summarise ("compact") old turns, some truncate long tool output, and if the input alone exceeds the window the API rejects the request (Anthropic documents a 400 prompt is too long error). The trade-off is the same in all of them: every token of noise displaces a token of signal.
A whole log fixed file CI log, 18,000 tokens answer left out: types, test, history (9,150) B failing test only fixed file types history free 9,550 answer log 350 types and test: 3,650 0 28k 32k
The same 32k window from the model above. A pasted log is the single biggest item in A and pushes out the types and the test; in B there's room for everything that matters.
AdvancedWhy context is everything

Fitting in the window isn't the same as being used well. Two findings worth naming:

  • Context rot. Anthropic's context-window docs (checked 2026-10-27) say it directly: "As token count grows, accuracy and recall degrade, a phenomenon known as context rot", so "curating what's in context [is] just as important as how much space is available."
  • Lost in the middle. Liu et al., "Lost in the Middle: How Language Models Use Long Contexts" (arXiv 2307.03172, July 2023; published in TACL), found models did best when the relevant information was at the start or end of a long input, and significantly worse when it was in the middle, even for models built for long contexts. Models have improved since; the direction of the advice hasn't changed. GitHub's prompt-engineering page for Copilot (checked 2026-10-27) says the same thing in product terms: "Copilot gives extra weight to instructions at the beginning and end of a prompt."

So the practical rules are: include what the task needs (the types, the test, the exact error), leave out what it doesn't (the whole log, unrelated files), and start a fresh session for a new task so the old one's history isn't competing for attention. GitHub's page lists "Start a new conversation or session when you begin a new task", and Claude Code's best-practices page recommends /clear between unrelated tasks "because a focused context gets you better answers and costs less."

AdvancedWhy they hallucinate APIs

Put the four facts together and invented APIs are what you'd expect, not a rare glitch:

  • Training mixes versions. The model saw Zod 3 code for years and Zod 4 for much less time, Express 4 far more than Express 5, early MCP SDK examples more than current ones. It writes the average of what it saw. In the Zod 4.6 installed for this course, z.string().email() still works but its declaration file marks it @deprecated Use z.email() instead; in the MCP TypeScript SDK 1.32.2, server.tool(...) is marked @deprecated Use registerTool instead. Older-style code from an assistant is common, and often still runs, which makes it easy to miss.
  • Neighbouring libraries bleed together. fs.readJson (fs-extra), axios-style options passed to fetch, a Lodash function called on a native array. Each is right somewhere.
  • Private APIs are invisible. Your company's SDK, a device's REST API, an internal helper: unless it's in the context, the model guesses from names, and the guess is shaped like a common public API.
  • Package names too. Spracklen et al., "We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs" (arXiv 2406.10279, June 2024, revised March 2025, USENIX Security 2025), generated 576,000 code samples with 16 models and found that on average at least 5.2% of the packages recommended by commercial models, and 21.7% of those recommended by open-source models, didn't exist: 205,474 unique hallucinated names. An attacker who registers such a name gets installed by anyone who trusts the suggestion (a practice sometimes called "slopsquatting").

The defence isn't a better prompt alone; it's a check in the loop: the compiler or type checker, the tests, a look at the installed package's exports and docs. Spotting hallucinated APIs and subtle bugs builds a checker that does exactly that against the real node_modules.

AdvancedSampling and why answers vary

Because each token is sampled, two runs of the same prompt can produce different code, and a "regenerate" button is just another draw. That has practical consequences:

  • One good answer isn't proof the prompt is good, and one bad answer isn't proof it's bad. If a team prompt file matters, try it several times.
  • Editor tools rarely let you set temperature or other sampling settings; they pick them for you. Don't plan on "set temperature to 0" as a reliability strategy, and remember the model above: low temperature only makes the most likely answer more likely, right or wrong.
  • Variety is useful on purpose. Asking for "three different approaches, with trade-offs" uses the same mechanism to explore alternatives (see Iterating and correcting).
AdvancedA big window isn't careful reading
Does temperature 0 fix hallucinated APIs?

A teammate says: "The assistant invented fs.readJson. If we could set temperature to 0, that would stop." Using the model above, is that right?

Show answer

No. Temperature changes how the probability is spread across candidates, not which candidates the model considers plausible. At low temperature the model picks the top-scoring name almost every time; if the top-scoring name is wrong, it's wrong every time.

What’s happening
  1. In the model, T=0.5 cut the nonexistent names from 21.8% to 8.5%, so lowering temperature did reduce them here, because a real name happened to have the highest score.
  2. Now imagine the context makes readJson the top score, for example because the surrounding file already uses fs-extra style calls. As T approaches 0, softmax gives almost all the probability to the top score, so the model would write readJson nearly every time: consistently wrong.
  3. The scores come from training data and context. Temperature only sharpens or flattens them, so it can't add knowledge the model lacks, such as which functions your installed node:fs exports.
  4. What actually fixes it is a check that knows the truth (the type checker or a run of the code) and better context (the real API in the prompt). That's why the answer to hallucination is verification, not settings.

#Where they help and where they hurt

Added

The honest answer to "do AI assistants make developers faster?" is it depends on the task, the codebase, the developer and how you measure, and the careful studies disagree. Assistants are strongest where the answer is a well-trodden pattern and checking it is cheap; weakest where correctness depends on facts that aren't in the context, on subtle invariants, or on security, and where you can't easily tell a right answer from an almost-right one.

The mental model is cheap to generate, expensive to verify. Generating code now costs seconds. The value of an assistant on a task is roughly the time it saves generating minus the time you spend verifying and fixing what it produced. Boilerplate in a typed codebase with tests: generation saving is large, verification is cheap, so you win. A concurrency fix in code you don't know: generation saving is small (the code is short), verification is expensive (you have to understand everything anyway), and a confident wrong answer can cost more than it saved.

What the studies say, with their caveats

Study (date)SettingResultCaveats the authors or the design imply
Peng, Kalliamvakou, Cihon, Demirer, "The Impact of AI on Developer Productivity: Evidence from GitHub Copilot" (arXiv, Feb 2023); GitHub blog by Kalliamvakou (Sept 2022)95 professional developers, randomised; write an HTTP server in JavaScriptCopilot group finished 55.8% faster (blog: 1 h 11 min vs 2 h 41 min; completion 78% vs 70%)one small, greenfield, well-defined task; run by GitHub researchers
Paradis et al., "How much does AI impact development speed? An enterprise-based randomized controlled trial" (arXiv, Oct 2024)96 Google engineers, one complex enterprise-grade task, Google's internal AI featuresabout 21% less timewide confidence interval; lab task; internal tools as of mid-2024
METR, "Measuring the Impact of Early-2025 AI on Experienced Open-Source Developer Productivity" (July 2025)16 experienced maintainers, 246 real issues in their own large repositories, randomised per task; mostly Cursor with Claude 3.5/3.7 Sonnettasks took 19% longer with AI allowed; developers had predicted 24% faster and afterwards still believed 20% fasterMETR stresses it doesn't show AI slows most developers; small sample; experts in code they know well
METR, "We are Changing our Developer Productivity Experiment Design" (2026-02-24)follow-up from Aug 2025: 57 developers, 800+ tasks, newer toolsestimates of 18% (returning developers, confidence interval −38% to +9%) and 4% (new developers, −15% to +9%) less time; METR's own wording is "a speedup of -18%"METR calls the estimate "likely a bad proxy": more developers declined to take part because they didn't want to work without AI, 30–50% withheld some tasks, and some ran several agents at once, so it's redesigning the study

Read together: controlled tasks that are self-contained show large speed-ups; experienced developers in large codebases they know showed none or a slowdown in early 2025; and the newest data is too biased to settle it. The METR result about perception is the one to remember: developers felt faster while being measured slower. Self-reported speed-ups aren't evidence.

The surveys measure usage and trust, not speed:

  • Stack Overflow Developer Survey 2025 (survey.stackoverflow.co/2025/ai): 84% use or plan to use AI tools; 32.7% trust the accuracy of AI output and 45.7% distrust it; the top frustration, chosen by 66%, is "AI solutions that are almost right, but not quite", and 45.2% say debugging AI-generated code is more time-consuming.
  • Stack Overflow Developer Survey 2026 (published October 2026): the top tasks people use AI for are generating code in a familiar area (69%), debugging, troubleshooting or refactoring (64%), straightforward technical questions (59%) and writing or improving tests (58%); 48% say they trust AI "when they can easily validate the answers". 2026 asks when developers trust AI, not 2025's question about how much they trust its accuracy, so the two years' trust numbers aren't comparable.
  • DORA, "State of AI-assisted Software Development 2025" (Google Cloud, 2025-09-23; nearly 5,000 respondents): 90% use AI at work and more than 80% believe it increased their productivity, while 30% report little or no trust in AI-generated code. DORA found a positive relationship between AI adoption and delivery throughput but a continuing negative one with delivery stability, and summarises AI's role as "an amplifier, magnifying an organization's existing strengths and weaknesses".

Security research points the same way as the "almost right" complaint:

  • Pearce et al., "Asleep at the Keyboard? Assessing the Security of GitHub Copilot's Code Contributions" (arXiv Aug 2021, IEEE S&P 2022): in 89 security-relevant scenarios, about 40% of 1,689 generated programs were vulnerable. That was 2021's Copilot; the lesson that security-sensitive suggestions need review hasn't aged.
  • Perry, Srivastava, Kumar, Boneh, "Do Users Write More Insecure Code with AI Assistants?" (ACM CCS 2023): participants with an AI assistant wrote significantly less secure code and were more likely to believe it was secure; those who trusted it less and engaged more with their prompts did better.

Here's what "almost right" looks like in security-sensitive code: a file download handler, and two fixes of the kind you'll see in suggestions and in reviews. All three pass the obvious demo.

path-traversal.mjs
// A download handler that looks finished, and two "fixes". Uses POSIX paths so the output is the same everywhere.
import path from "node:path/posix";

const ROOT = "/srv/app/uploads";

// v1: the version that passes a happy-path demo
const v1 = (name) => path.join(ROOT, name);

// v2: a common "fix": resolve, then check the prefix
const v2 = (name) => {
  const full = path.resolve(ROOT, name);
  return full.startsWith(ROOT) ? full : null;
};

// v3: resolve, then require the root itself or root + "/" as the prefix
const v3 = (name) => {
  const full = path.resolve(ROOT, name);
  return full === ROOT || full.startsWith(ROOT + path.sep) ? full : null;
};

const inputs = ["report.pdf", "2026/q3.csv", "../../../etc/passwd", "/etc/passwd", "../uploads-old/keys.pem"];
const show = (p) => (p === null ? "refused" : p.startsWith(ROOT + "/") ? p : `${p}  ← outside`);
const versions = [["v1 join", v1], ["v2 startsWith(ROOT)", v2], ["v3 ROOT + \"/\"", v3]];
for (const name of inputs) {
  console.log(name);
  for (const [label, fn] of versions) console.log(`  ${(label + ":").padEnd(21)} ${show(fn(name))}`);
}
Result
report.pdf
  v1 join:              /srv/app/uploads/report.pdf
  v2 startsWith(ROOT):  /srv/app/uploads/report.pdf
  v3 ROOT + "/":        /srv/app/uploads/report.pdf
2026/q3.csv
  v1 join:              /srv/app/uploads/2026/q3.csv
  v2 startsWith(ROOT):  /srv/app/uploads/2026/q3.csv
  v3 ROOT + "/":        /srv/app/uploads/2026/q3.csv
../../../etc/passwd
  v1 join:              /etc/passwd  ← outside
  v2 startsWith(ROOT):  refused
  v3 ROOT + "/":        refused
/etc/passwd
  v1 join:              /srv/app/uploads/etc/passwd
  v2 startsWith(ROOT):  refused
  v3 ROOT + "/":        refused
../uploads-old/keys.pem
  v1 join:              /srv/app/uploads-old/keys.pem  ← outside
  v2 startsWith(ROOT):  /srv/app/uploads-old/keys.pem  ← outside
  v3 ROOT + "/":        refused
What’s happening
  1. v1 is path.join(ROOT, name), the shape that appears everywhere in tutorials. report.pdf and 2026/q3.csv work, so a happy-path demo passes. But join happily processes ..: "../../../etc/passwd" normalises to /etc/passwd, outside the uploads folder. This is a path-traversal vulnerability (CWE-22), and the code contains nothing that looks wrong.
  2. Notice /etc/passwd under v1: join treats an absolute second argument as just more segments, giving /srv/app/uploads/etc/passwd, which is safe by accident. Swap join for resolve without a check and the same input escapes, because resolve restarts at an absolute path.
  3. v2 is the common fix: resolve, then startsWith(ROOT). It refuses ../../../etc/passwd and /etc/passwd, and it looks finished. But "../uploads-old/keys.pem" resolves to /srv/app/uploads-old/keys.pem, and that string does start with /srv/app/uploads. A sibling folder whose name begins with the root's name is readable.
  4. v3 requires the resolved path to be the root itself or to start with ROOT + "/", so uploads-old no longer matches. That's the version to ship, plus what a reviewer would still ask about: symlinks inside the folder (resolve doesn't follow them; fs.realpath does), and URL-encoded input (%2e%2e%2f) if decoding happens after the check.
  5. Every version here would be accepted by a reviewer skimming for "does it use path?". The bug is in the edge cases, which is exactly where generated code is weakest and where human review has to concentrate.
AdvancedSubtle logic: daily meetings across DST

Subtle logic: a daily meeting across a DST change

Time is the classic "short, plausible, wrong" domain. A booking system for meeting rooms repeats a 09:00 meeting daily; the obvious implementation adds 24 hours. In Toronto, daylight saving time ends on Sunday 1 November 2026.

dst-repeat.mjs
// "Repeat this 09:00 meeting daily": adding 24 hours vs adding a calendar day, across the end of daylight
// saving time in Toronto (Sunday 2026-11-01, when clocks go from 02:00 back to 01:00).
const TZ = "America/Toronto";
const fmt = new Intl.DateTimeFormat("en-CA", {
  timeZone: TZ, weekday: "short", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false,
});
const offsetMinutes = (d) => { // the zone's UTC offset at instant d, in minutes
  const p = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: TZ, hourCycle: "h23",
    year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
    .formatToParts(d).map((x) => [x.type, x.value]));
  return (Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute) - d.getTime()) / 60000;
};

const first = new Date("2026-10-30T13:00:00Z"); // Friday 09:00 in Toronto (EDT, UTC-4)

// Plausible version: a day is 24 hours of milliseconds
const plusHours = (d, n) => new Date(d.getTime() + n * 24 * 3600 * 1000);

// Calendar version: keep the local wall-clock time, then convert back with that day's offset
function plusCalendarDays(d, n) {
  const guess = new Date(d.getTime() + n * 24 * 3600 * 1000);
  return new Date(guess.getTime() + (offsetMinutes(d) - offsetMinutes(guess)) * 60000);
}

for (let n = 0; n <= 3; n++) {
  console.log(`day ${n}: +24h ${fmt.format(plusHours(first, n)).padEnd(22)} calendar ${fmt.format(plusCalendarDays(first, n))}`);
}
Result
day 0: +24h Fri, Oct 30, 09:00     calendar Fri, Oct 30, 09:00
day 1: +24h Sat, Oct 31, 09:00     calendar Sat, Oct 31, 09:00
day 2: +24h Sun, Nov 1, 08:00      calendar Sun, Nov 1, 09:00
day 3: +24h Mon, Nov 2, 08:00      calendar Mon, Nov 2, 09:00
What’s happening
  1. first is 2026-10-30T13:00:00Z, which is Friday 09:00 in Toronto, because Eastern Daylight Time is UTC−4. fmt formats instants in America/Toronto with Node's built-in Intl time-zone data, so the output doesn't depend on the machine's own zone.
  2. plusHours adds n × 24 × 3600 × 1000 milliseconds. Friday and Saturday print 09:00. On Sunday the clocks went back an hour at 02:00, so 72 hours after Friday 09:00 EDT is Sunday 08:00 EST. Every meeting from then on is an hour early.
  3. plusCalendarDays starts from the same guess and corrects it by the change in the zone's UTC offset between the two instants (offsetMinutes reads it from Intl): −240 minutes before, −300 after, so it adds 60 minutes back. The meeting stays at 09:00.
  4. Nothing in the 24-hour version looks wrong, and it passes any test written in October. That's what makes this domain dangerous for generated code: the bug only exists on two days a year, in some time zones. (Writing this model, the correction's sign was wrong on the first try and printed 07:00; the run caught it.)
  5. In production, use a date library or Temporal.ZonedDateTime where your runtime supports it (it isn't enabled by default in the Node 26.7 used for this page), and give an assistant the DST dates for your zones as test cases before asking for the code.
AdvancedWhere they usually help
  • Boilerplate and repetition: the fifth route handler, DTOs from a schema, mapping code, config files, migrations that follow a pattern already in the repo.
  • Tests for code that exists: a first set of cases, including edge cases you might not list, if you review them (assistants also write tests that assert the current bug; see Tests first with an assistant). GitHub's best-practices page lists "Writing tests and repetitive code" first among what Copilot is good at.
  • Mechanical refactors with a safety net: renames across files, extracting a function, converting callbacks to async/await, when tests and the type checker will catch mistakes.
  • Explaining unfamiliar code or errors: a starting hypothesis about what a function or a stack trace means, faster than reading cold. Check the parts you'll rely on.
  • One-off scripts, regexes, shell pipelines, SQL: small, verifiable by running them on a sample.
  • Translating between languages or frameworks you know on both sides (Angular to React, a Python script to Node), where you can judge the result.
AdvancedWhere they usually hurt
  • Subtle logic: time zones and DST, money and rounding, concurrency and ordering, retries and idempotency, caching and invalidation. The code is short and plausible; the bug is an invariant the model didn't know.
  • Security-sensitive code: auth, input handling, file paths, SQL, crypto, secrets. The studies above, and the model above, are why.
  • Code you can't evaluate: an unfamiliar language, framework or domain. "Looks fine" from someone who can't tell is the most dangerous review there is.
  • Version-sensitive and private APIs: new library majors, your internal SDK, a device's REST API. The model writes the API it expects to exist.
  • Large, cross-cutting design decisions: an assistant will happily produce an architecture; it can't know your team's constraints, history and operating costs unless you tell it, and its confident structure can anchor a discussion that should have stayed open.
  • When there's no check: no tests, no types, no way to run it. Then every generated line is only as good as your reading of it.
Advanced"The demo passes" is the most expensive phrase
Help or hurt?

Rate each as "good fit", "use with heavy review" or "write it yourself (maybe ask for a review)": (a) a Zod schema and TypeScript type for a JSON payload you have a sample of; (b) a function that converts meeting times between the device's local time zone and UTC around a DST change; (c) a script that renames 200 files according to a pattern, run once on your machine.

Show answer

(a) good fit, (b) heavy review at least (many teams would write it with a date library and review the tests closely), (c) good fit, with a dry run first.

What’s happening
  1. (a) The sample payload is in the context, the pattern is extremely common, and verification is cheap: parse the sample with the schema and look at the inferred type. Check it uses the Zod version you have installed (Zod 4 prefers z.email() over z.string().email()).
  2. (b) Time zones are the classic "short, plausible, wrong" domain: the bug is in the hour that happens twice or not at all, and a model's code often handles the normal case and silently breaks there. If you use an assistant, give it the exact boundary cases (the DST dates for the zones you ship) as tests first and review those tests line by line.
  3. (c) A one-off script is a great use: you can read it fully, run it with a dry-run flag that prints the renames, and check the output before doing it for real. The cost of a mistake is bounded and visible.
  4. The pattern behind the ratings: how expensive is it to verify, and how bad is an almost-right answer? Cheap to verify and visible when wrong is a good fit; expensive to verify and silent when wrong needs you.

#The developer's job with an assistant

Added

Using an assistant well isn't a typing skill; it's three engineering skills you already have, applied more often: specify what you want precisely enough that the gap can't be filled with a plausible guess, review what comes back as critically as a colleague's pull request, and verify it with checks that don't depend on anyone's confidence. And one rule that doesn't change: ownership doesn't move. Whoever opens the pull request owns the code, whoever approves it shares that, and "the AI wrote it" is not an explanation anyone accepts in an incident review.

The vendors say the same in their own docs. GitHub's best-practices page for Copilot (checked 2026-10-27) says "you should always validate the code it suggests": understand it first (you can "ask Copilot Chat to explain the code"), review "not just the functionality and security of the suggested code, but also the readability and maintainability", and "use automated tests and tooling to check Copilot's work" (linting, code scanning, IP scanning). Its prompt-engineering page says to "validate AI-generated code and responses the same way you would review a colleague's work". Claude Code's best-practices page puts the verification side bluntly: if the agent has nothing to run, "that makes you the test suite", so give it something that "comes back pass or fail", and ask it for evidence, because "'All tests pass' on its own tells you very little".

The mental model: you're the tech lead of a tireless, extremely well-read contributor who never says "I'm not sure" unless asked. You wouldn't merge a new hire's change because it compiled and they sounded confident. You'd check it against the ticket, look hardest at the risky parts, and make sure the tests prove what matters. The difference is volume: an assistant produces a pull request's worth of code in a minute, so the review and verification habits have to be fast, systematic and partly automated.

StageYour jobWhat it looks like with an assistant
Specifysay what done meansbehaviour, edge cases, constraints, versions, a failing test (see Spec first)
Generatechoose the mode and the contextcompletion for local patterns, chat for questions, an agent for a well-checked task
Verifyprove it, don't believe itrun the tests, the type checker and linters; try the hostile inputs; ask for the command output
Reviewread the diff as if a colleague wrote itcorrectness, security, fit with the codebase, tests that test the right thing, no new dependencies you didn't ask for
Ownsign ityou can explain every line in the PR and you'd defend it in an incident review

Here is what "verify, don't believe" looks like on a small function. The candidate is the kind of plausible one-liner that shows up in suggestions and in human code alike (it was written for this page), and the check is a slow, obviously correct oracle plus seeded random inputs.

verify-median.mjs
// "Looks right, passes the examples": checking a plausible median() against a slow, obviously-correct oracle.
const candidate = (xs) => xs.sort()[Math.floor(xs.length / 2)]; // written for this page, not from an AI tool

const oracle = (xs) => {
  const s = [...xs].sort((a, b) => a - b);
  const mid = s.length / 2;
  return s.length % 2 ? s[Math.floor(mid)] : (s[mid - 1] + s[mid]) / 2;
};

// 1. The examples someone would try by hand
for (const xs of [[1, 2, 3], [3, 1, 2], [5]]) console.log(`median(${JSON.stringify(xs)}) = ${candidate([...xs])}  oracle ${oracle(xs)}`);

// 2. Seeded random inputs (mulberry32), so the run is reproducible
function mulberry32(seed) {
  return () => {
    seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(7);
const failures = { wrongValue: [], mutatesInput: 0 };
for (let i = 0; i < 500; i++) {
  const xs = Array.from({ length: 1 + Math.floor(rand() * 6) }, () => Math.floor(rand() * 30));
  const before = JSON.stringify(xs);
  const got = candidate(xs);
  if (JSON.stringify(xs) !== before) failures.mutatesInput++;
  const want = oracle(JSON.parse(before));
  if (got !== want) failures.wrongValue.push({ xs: JSON.parse(before), got, want });
}
console.log(`500 random arrays: ${failures.wrongValue.length} wrong answers, input mutated ${failures.mutatesInput} times`);

// 3. The smallest failing cases are the useful bug reports
const byOdd = (odd) => failures.wrongValue.filter((f) => f.xs.length % 2 === (odd ? 1 : 0));
for (const [label, list] of [["odd length", byOdd(true)], ["even length", byOdd(false)]]) {
  const f = list.sort((a, b) => a.xs.length - b.xs.length || Math.max(...a.xs) - Math.max(...b.xs))[0];
  console.log(`smallest ${label} failure: ${JSON.stringify(f.xs)} → got ${f.got}, want ${f.want}`);
}
Result
median([1,2,3]) = 2  oracle 2
median([3,1,2]) = 2  oracle 2
median([5]) = 5  oracle 5
500 random arrays: 348 wrong answers, input mutated 357 times
smallest odd length failure: [9,10,3] → got 3, want 9
smallest even length failure: [0,3] → got 3, want 1.5
What’s happening
  1. The first three lines are the checks a person would try by hand: [1,2,3], [3,1,2] and [5]. The candidate passes all three. If this was your review, you'd approve it.
  2. The oracle is deliberately boring: copy the array, sort it numerically with (a, b) => a - b, and average the two middle values for even lengths. It's slower and longer, and it's obviously right, which is all an oracle needs to be.
  3. 500 seeded random arrays (mulberry32, so the same arrays every run) find 348 wrong answers and 357 calls that mutated the caller's array. Two bugs hide in one line: sort() with no comparator sorts as strings, and sort() sorts in place, so calling median(scores) reorders scores for everyone else.
  4. The smallest failures are the useful bug report. [9,10,3]: as strings, "10" < "3" < "9", so the "sorted" array is [10,3,9] and the middle element is 3, not 9. [0,3]: the candidate takes the element at index 1 (3) and never averages, so even lengths are always wrong.
  5. The demonstration is about the check, not the bug: hand-picked examples share your blind spots, while an oracle and generated inputs don't. When a function is easy to state but easy to get subtly wrong, a property check like this costs ten lines and is the cheapest way to verify generated code you don't want to trace by hand.
AdvancedEvidence, not claims

The single most useful habit with agents is to ask for evidence and read it: the command that ran, its exit code and its output; the screenshot next to the mockup; the before-and-after query plan. "I've fixed it and all tests pass" is a claim. node --test output showing # pass 42 # fail 0, on the branch you're about to merge, is evidence. Claude Code's docs make the same point: reading the output "is faster than rerunning the check yourself", and for a session you weren't watching "it's also the only evidence you have".

Practical rules that follow:

  • Define done before you start: a test to turn green, a command that must exit 0, a page that must match a mockup. An agent with a check can loop until it passes; without one, it stops when the work looks done.
  • Keep changes small enough to review. A 1,500-line AI-generated diff gets skimmed, and skimmed diffs are where the path-traversal bug ships. Ask for one concern per change.
  • Review the tests separately from the code. If the same assistant wrote both, a wrong understanding shows up in both, and they agree with each other.
  • Watch for scope creep: new dependencies, reformatted files, "while I was there" refactors, deleted tests. None of them were in the ticket.
  • Be able to explain every line. If you can't explain why a line is there, you can't own it; ask, test or rewrite it.
AdvancedCheck the shape of the diff first

Before reading an agent's diff line by line, check that its shape matches the ticket: which files changed, whether tests were deleted, whether dependencies or CI changed. It takes seconds and catches the scope creep agents are prone to. The diff summary below is made up for the example, in git diff --numstat format.

scope-check.mjs
// Before reading an agent's diff line by line, check its shape against the ticket.
// The diff summary is made up for this page (git diff --numstat style: added, deleted, path).
const ticket = { title: "Return 409 when a room is double-booked", scope: ["src/rooms/", "test/rooms/"] };
const numstat = `
42	3	src/rooms/bookings.js
18	0	test/rooms/bookings.test.js
0	27	test/rooms/conflicts.test.js
2	0	package.json
311	4	package-lock.json
6	6	.github/workflows/ci.yml
120	118	src/lib/format.js`;
const packageJsonDiff = `+    "date-fns-tz-lite": "^0.3.1",`; // the added dependency line

const flags = [];
for (const line of numstat.trim().split("\n")) {
  const [added, deleted, path] = line.split("\t");
  const inScope = ticket.scope.some((s) => path.startsWith(s));
  if (path.startsWith("test/") && Number(added) === 0 && Number(deleted) > 0) flags.push(`${path}: ${deleted} test lines deleted, none added`);
  else if (path === "package.json") flags.push(`${path}: new dependency ${packageJsonDiff.match(/"([^"]+)"/)[1]} (not in the ticket)`);
  else if (path.endsWith("-lock.json")) flags.push(`${path}: lockfile changed (${added} lines): follows the new dependency`);
  else if (path.startsWith(".github/")) flags.push(`${path}: CI config changed`);
  else if (!inScope && Math.abs(Number(added) - Number(deleted)) < 10 && Number(added) > 50) flags.push(`${path}: ${added}+/${deleted}- outside scope, looks like a reformat`);
  else if (!inScope) flags.push(`${path}: outside the ticket's scope`);
}
console.log(`ticket: ${ticket.title}`);
console.log(flags.length ? flags.map((f) => "  ⚑ " + f).join("\n") : "  shape matches the ticket");
Result
ticket: Return 409 when a room is double-booked
  ⚑ test/rooms/conflicts.test.js: 27 test lines deleted, none added
  ⚑ package.json: new dependency date-fns-tz-lite (not in the ticket)
  ⚑ package-lock.json: lockfile changed (311 lines): follows the new dependency
  ⚑ .github/workflows/ci.yml: CI config changed
  ⚑ src/lib/format.js: 120+/118- outside scope, looks like a reformat
What’s happening
  1. The ticket's scope is src/rooms/ and test/rooms/. bookings.js and bookings.test.js are in scope and look proportionate (42 and 18 lines added), so they produce no flag.
  2. conflicts.test.js lost 27 lines and gained none. Deleted tests are the first thing to ask about: an agent told to "make the tests pass" can get there by removing the inconvenient ones. Sometimes it's legitimate (the tests moved); the reviewer has to know which.
  3. package.json gained date-fns-tz-lite, which the ticket never mentioned, and the lockfile changed by 311 lines because of it. That name didn't exist on the npm registry when checked on 2026-10-27, which is exactly the hallucinated-package risk: if someone registers it, it installs. Every new dependency needs a human decision.
  4. The CI workflow changed (why does a 409 fix touch CI?), and src/lib/format.js shows 120 added and 118 deleted lines outside the scope, the signature of a reformat. Both make the real change harder to review; ask for them to be reverted or split into their own pull requests.
  5. None of this needs the code to be read yet. In a team this check can be a CI job or a review bot; the AI in the Team chapter turns it into a review checklist.
AdvancedWhat doesn't move to the tool

Some responsibilities stay with people no matter how good the tools get, and an interviewer for a lead role will listen for them:

  • Correctness and security of what ships: the reviewer and the author are accountable, as they were before.
  • Licensing and data: what code and data you paste into which tool, and whether generated code may match licensed public code. That's a team policy question (see Security, licensing and data concerns).
  • Design and trade-offs: the assistant can list options; deciding which constraints matter for your system is the job.
  • Understanding: when the 2 a.m. incident comes, the person on call needs to know how the code works, whoever typed it.
AdvancedAgreeing tests aren't independent tests
Why did the candidate return 3 for [9, 10, 3]?

Trace [9, 10, 3].sort()[Math.floor(3 / 2)] step by step. What would you change, and what else is wrong with the candidate?

Show answer

It returns 3 because sort() compares elements as strings. The fix is to copy, sort numerically and average the middle pair for even lengths: the oracle's code.

What’s happening
  1. Array.prototype.sort with no comparator converts elements to strings and compares them by UTF-16 code units: "10", "3", "9".
  2. "10" comes first because its first character "1" is less than "3" and "9". So the array becomes [10, 3, 9], in place.
  3. Math.floor(3 / 2) is 1, and index 1 holds 3. The true median is 9.
  4. Two more problems: the original array is now [10, 3, 9] for every other piece of code holding a reference to it (mutation), and an even-length input like [0, 3] returns 3 instead of 1.5, because the function never averages.
  5. The corrected version is [...xs].sort((a, b) => a - b) (a copy, sorted numerically), then the middle element or the average of the two middle elements. ES2023's toSorted((a, b) => a - b) does the copy and sort in one call.

Written fresh for a role that asks for GitHub Copilot and MCP experience. MCP examples were run with @modelcontextprotocol/sdk 1.32.2; Copilot facts come from GitHub's documentation, cited with dates.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.