Ditching Agent Frameworks When AI is Just a Supporting Role, 300 Lines is Enough
Building the loop was easy. Deciding what to give up wasn't.
I spent an evening working out how askStudent would fit into LangChain.js before I gave up on it. The tool needed to halt the agent mid-run, hand a question to a React component, and then sit there — for thirty seconds or five minutes — until a human typed something back. Every approach I found came down to either a callback firing at the wrong moment or splitting the run into two halves with the state ferried between them, and both were more code than the thing I was trying to avoid writing in the first place.
So I wrote it myself. The Agent that shipped is a while loop in roughly 300 lines of JavaScript. The line count isn't really the point, and I'll be specific further down about what it does and doesn't include. The point is that once this feature's actual requirements were clear, every framework I looked at was solving a problem I didn't have while making the one I did have harder.
What the System Actually Does
ai-introduction-course-defense-system is an automated oral-exam platform for an intro AI course. The main loop is unremarkable: the system shows a Python problem (implement K-Means, implement KNN), the student writes code in a browser editor, the code runs locally through Pyodide, and when they're happy with it they click "Submit for Defense."
The problems are LLM-generated too. A teacher gives a topic and a problem type; the model returns JSON with a title, a Markdown description, a starter scaffold, and a hint. The scaffold gets syntax-checked in Pyodide, and if it fails, the error goes back to the model for a fix — up to three attempts. That path needs no tools and no state. One call in, one JSON out.
The Agent only appears at the last step, playing a deliberately humorless "Defense Examiner" that works through a fixed sequence: read the code, run a baseline test, inject edge cases, ask one conceptual question, assign a grade.
So the product is a coding practice platform. The Agent is one feature inside one step of it. That framing drove every decision below.
Why I Didn't Use a Framework
The two requirements that actually decided it
Two things about this Agent are unusual, and they're the two that made up my mind.
The first is where tools execute. injectTestCase has to run Python inside the browser's live Pyodide instance and keep the global namespace intact between calls, so a function the student defined in step one is still callable in step three. The tool isn't calling out to a sandbox service somewhere; it's reaching into a WASM runtime that the rest of the page also owns.
The second is suspension. When the Agent calls askStudent, the loop has to stop — not poll, not time out, stop — until a human reads the question, types an answer, and hits send. That pause might last thirty seconds or five minutes.
Neither is exotic on its own. But both cross the boundary between the Agent and the UI, and that boundary is exactly what a framework abstracts away for you. Whichever one I picked, I'd have spent my effort reaching back through its abstractions to get at things it had helpfully hidden.
The runtime constraint is real, but smaller than it looks
It's tempting to say frameworks can't run in a browser. That isn't true — LangChain.js supports browser and edge runtimes, and the Vercel AI SDK is built for the client.
The honest version is about weight and depth. This app already ships Pyodide, a multi-megabyte WASM payload that loads before a single line of my code does, and I wasn't eager to put a framework's dependency tree on top of that. And with no backend at all, everything a framework does happens in the same tab the student is typing in. When something goes wrong I'm reading a stack trace through three layers of abstraction, in a browser console, on a machine I don't control, during a live exam. Thin layers were worth a lot to me here.
The pipeline is a straight line
Five steps, in order. No branches, no concurrency, no second agent. LangGraph is built to manage state machines considerably more interesting than that.
I want to be fair about this one, though, because LangGraph's most relevant feature is the one I ended up reimplementing badly. Its interrupt plus a checkpointer is precisely the "stop, wait for a human, resume" pattern I needed — and unlike my version, it survives the process going away. I traded that off knowingly. The bill for it is further down.
How It's Actually Built
Three layers. The core — invocation layer, task loop, and the tool contract — comes to under 300 lines. The six tool implementations, the SSE parser's error handling, the prompts, and all the UI wiring are not in that number. Worth saying out loud, because "300 lines" is the kind of claim that holds up right until someone clones the repo.
Layer 1: the LLM invocation layer
This layer sends messages and streams responses back. That's the whole job.
No SDK — just fetch against an OpenAI-compatible /chat/completions endpoint, with a hand-written SSE parser. The parser exists because three streams arrive interleaved and all three matter: ordinary text (content), reasoning tokens (reasoning_content, which DeepSeek and friends emit), and tool call JSON arriving a few characters at a time (tool_calls).
// Concatenating three streams simultaneously
if (delta.content) {
content += delta.content;
onText?.(delta.content); // Trigger real-time UI rendering
}
if (delta.reasoning_content) {
thinking += delta.reasoning_content;
onThinking?.(delta.reasoning_content);
}
if (Array.isArray(delta.tool_calls)) {
for (const tcDelta of delta.tool_calls) {
// Concatenate tool name and argument JSON increments
}
}
The layer also swallows one piece of vendor trivia: some models reject temperature outright with a 400. When the invocation layer sees that specific error, it drops the parameter and retries. Callers never find out.
Layer 2: the tool layer
Six tools. Each is a file exporting { definition, handler } and registering itself into a global registry as an import side effect.
Handlers take parameters and return data. They don't touch state outside themselves. What comes back is a fixed shape:
return {
content: "String result for the LLM to read",
asAiMessage: true/false, // Should this result be displayed in the UI as the AI's message?
waitForReply: true/false, // Should the engine suspend and wait for user input?
finalResult: "...", // If present, the task is complete
shouldBreak: true/false // Should the Agent loop be forcefully exited?
};
Those five fields are the entire vocabulary between a tool and the engine. A tool can say "wait for a reply" or "we're done"; the engine decides what that means. The engine never learns what a tool did internally.
The compromise is worth flagging, since it's the part of the design I'm least sure about. asAiMessage and waitForReply aren't data — they're presentation and control-flow instructions hitching a ride on the result, which is a funny thing to find in a return value from a function that supposedly only returns data. A stricter design would have the engine derive both from the tool's identity and keep the payload pure. I went with the loose version because it keeps each tool's behaviour readable in one file, and because six tools is small enough that the coupling hasn't cost me anything yet. At twenty I'd probably regret it.
Layer 3: the task loop
task-loop.js, and it's what it sounds like: build messages, call the LLM, run a tool, handle the result, repeat.
Message building deserves a note. The System Prompt is rebuilt before every call, with the current problem statement and a fresh snapshot of the student's code injected each time. So if a student quietly edits their solution halfway through the defense, the examiner sees the edit on the next turn instead of grading a stale copy.
Single-step execution is enforced. If the model returns three tool_calls, the engine runs the first, discards the rest, and tells the model what it did in the next prompt. Letting a model fan out across tools it picked in one breath was, in testing, the fastest route to an incoherent transcript.
Suspension is the part I like. When askStudent returns waitForReply: true, the loop doesn't return — it awaits a Promise whose resolve has been handed out to a variable the UI can reach:
if (result.waitForReply) {
// Change state to notify the UI to show the input box
setState(TaskState.WAITING_FOR_STUDENT);
// Create a Promise and expose its resolve to the outside
const studentReply = await new Promise((resolve) => {
askResolver = (text) => {
askResolver = null;
resolve(text);
};
});
// The code only reaches here after the Promise is resolved
appendUser(studentReply);
setState(TaskState.THINKING);
}
agent.submitStudentMessage(text) calls askResolver(text), the await unblocks, and the loop carries on with the student's answer in the message history. It's ordinary JavaScript. The only trick is being willing to let a resolve escape its closure.
Then the guardrails, because a loop like this will misbehave in front of a class if you let it:
| Safeguard | Trigger Condition | Response |
|---|---|---|
| Maximum iterations | Exceeds 30 rounds | Force-terminate the task |
| No-tool-call penalty | 2 consecutive rounds with no tool call | Force-terminate the task |
| Dead-loop detection | Same tool + same args appear ≥ 4 times in a 10-step window | Intercept execution, inject a prompt forcing the LLM to try a different approach |
| LLM call failure | Network error or API error | Exponential backoff retry, up to 3 attempts |
None of these were designed in advance. The dead-loop detector exists because a model decided a student's KNN implementation ought to be tested against an empty input, got an exception back, and then injected the identical test case four more times — apparently expecting a different answer on the fifth. The no-tool-call penalty exists because another run announced its grade in plain prose instead of calling the tool that actually ends the defense, and then carried on chatting, quite pleasantly, with a student who had already closed the tab.
The thresholds themselves — four repeats inside ten steps, two silent rounds — are just where I happened to get burned. There's no theory behind the numbers.
What This Design Gives Up
Rolling your own means you own the parts nobody puts in a demo.
Refreshing the page ends the defense. The suspension mechanism lives in memory. Close the tab, lose the tab, hit F5 — the Promise is gone and the session is unrecoverable. This is the cost I mentioned earlier, and for a graded exercise it's a real one. A checkpointed framework, or even a modest backend with a session store, would fix it. Neither was in scope.
There's no observability worth the name. Framework users get tracing, token accounting, and replayable runs largely for free. I have console.log. When a defense goes strange, I reconstruct what happened from the transcript and guesswork.
That guardrail table is a small framework. Iteration caps, backoff, loop detection — they're in every mature framework because everyone who writes an agent loop eventually needs them. I didn't avoid that work, I deferred it until each item hurt enough to fix. That's a defensible strategy. It isn't the same as the work being unnecessary, and I'd be lying if I called those four rows anything other than reinvention.
Nothing here is secure, and for an exam that matters. The whole system is client-side. The prompts, the tool definitions, the grading criteria and the API key all live in a bundle the student can read, and every network call is theirs to intercept. A student motivated enough can get a passing grade without writing K-Means at all. I built this for a setting where that isn't the threat I'm defending against — but if it were carrying real assessment weight, the Agent would have to move behind a server, and a good half of the reasoning in this post would change with it. Better to say that plainly than let the post imply otherwise.
When I'd Reach for a Framework
Not as a rhetorical concession — these are the cases where I'd have lost this argument:
- The Agent is the product rather than a step inside one.
- Runs have to survive a crash or a refresh. Checkpointing is genuinely unpleasant to retrofit.
- Branching, retries over subgraphs, or more than one agent in the conversation.
- Several people maintaining the agent code, where a shared vocabulary beats a tailored one.
- You need traces and evals from day one — anything whose output somebody has to audit later.
Frameworks are good at orchestration and bad at fitting into a room they didn't design. Mine was a small room.
Closing
The implementation isn't clever. A while loop, a Promise held open across a UI boundary, and one small contract between tools and the engine covered the whole job, and all the interesting work was in figuring out what the job actually was.
What I'd take from it isn't "skip the framework." It's that "is my Agent the product, or a feature inside one?" settles most of the architecture before you've opened anybody's documentation.