Ditching Agent Frameworks: When AI is Just a Supporting Role, 300 Lines is Enough

Not Every AI Feature Needs an Agent Framework Behind It

With the explosion of Large Language Models, the concept of AI Agents has taken the tech world by storm. Whenever developers want to add Agent capabilities to a project, their first instinct is usually to open the LangChain, CrewAI, or LangGraph documentation. These frameworks are genuinely powerful—but when I built ai-introduction-course-defense-system, I ultimately abandoned all of them and handcrafted a complete Agent in fewer than 300 lines of pure JavaScript.

This article covers two things: why I didn't use a framework, and exactly how I built it.

What the System Actually Does

ai-introduction-course-defense-system is an automated oral examination platform for AI courses. Its core workflow is entirely conventional: the system presents a Python programming problem (like implementing K-Means or KNN), the student writes code in a browser-based editor, the code executes locally via Pyodide (a WebAssembly Python runtime), and once the student is satisfied, they click "Submit for Defense."

The problems themselves are also generated by an LLM. A teacher inputs a topic and problem type, and the system calls the model to return a structured JSON containing the problem title, a Markdown-formatted description, a starter code scaffold, and a hint. After generation, the starter code is syntax-checked using Pyodide. If there are any errors, the error message is fed back to the LLM for correction, with up to three retries. This part of the logic is relatively straightforward—a one-shot single-turn call with no tool invocations and no state management required.

Only at that final step does the AI Agent step in. It plays the role of a strict "Defense Examiner," following a rigid five-step process: read the code → run a baseline test → inject edge-case tests → ask the student a conceptual question → deliver a final grade.

The main product here is an online coding practice platform. The AI Agent is just one feature within a single step of that product.

Why Not Use a Mainstream Agent Framework

The Hard Constraint of the Runtime Environment

This is the most fundamental reason. Frameworks like LangChain and CrewAI are designed to run on Node.js or Python backend servers, with deep dependencies on file systems, network libraries, and process management. This system is a pure frontend application with no backend server—Python code runs locally in the browser via Pyodide. Forcing a massive server-side framework into a browser sandbox is simply not viable.

AI is the Supporting Role; the Business is the Lead

Agent frameworks are built around the assumption that your entire application revolves around the Agent. But in this system, the student is the main character. The AI is a passively triggered evaluator. The backbone of the system is the code editor, the Pyodide execution environment, and problem storage. The Agent is just one puzzle piece fitted into that structure. Introducing massive infrastructure for a single feature is putting the cart before the horse.

The Business Requirements are Fundamentally Incompatible with Framework Abstractions

My Agent has two highly specific requirements that no existing framework supports out of the box.

The first is sandboxed execution. The injectTestCase tool must execute Python code inside the in-memory Pyodide instance in the browser, while preserving global state across calls (so that functions the student defined are still available in the next test run).

The second is asynchronous suspension. When the Agent calls askStudent to ask a question, the entire execution loop must completely pause until the real user types a response in the UI and clicks send. This kind of long-duration interruption that crosses UI boundaries has no corresponding abstraction in any server-side framework.

Forcing a framework to accommodate these two requirements would mean spending significant effort breaking its abstraction layers. Writing it from scratch is far cleaner.

A Linear Pipeline Doesn't Need a Graph State Machine

The defense process is a mandatory sequential five-step pipeline with no branching, no concurrency, and no multi-agent collaboration. Using LangGraph—a framework designed to manage complex directed graph state machines—to run a straight-line pipeline is like using a sledgehammer to crack a nut.

How It's Actually Built

Once I dropped the frameworks, I found that handcrafting an Agent that perfectly fits the business needs was surprisingly straightforward. The entire implementation is divided into three clean layers, with the core code totaling fewer than 300 lines.

Layer 1: The LLM Invocation Layer

This layer does exactly one thing: send messages out and stream responses back.

I didn't use any official SDK. Instead, I used native fetch to call the OpenAI-compatible /chat/completions endpoint and manually parsed the Server-Sent Events (SSE) data stream. The reason for writing a custom parser is that we need to handle three distinct streams simultaneously: standard text content (content), chain-of-thought output (reasoning_content, for compatibility with models like DeepSeek), and incremental tool call JSON (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
}
}

This layer also handles a practical engineering pain point: some models don't support the temperature parameter and throw an HTTP 400 if it's included. When the invocation layer catches this specific error, it automatically strips the parameter and retries seamlessly—the caller never knows it happened.

Layer 2: The Tool Layer

The system has 6 tools. Each tool is a separate file that exports a { definition, handler } object and auto-registers itself into a global registry via a side-effect import.

The design principle for tools is: only accept parameters, only return data, never directly mutate external state. After a tool's handler executes, it returns a standardized result object:

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?
};

These five fields are the entire "language" between tools and the engine. A tool says "I need to wait for a reply." A tool says "the defense is over." The engine acts accordingly. The tool doesn't care how the state machine transitions, and the engine doesn't care what the tool does internally.

Layer 3: The Task Loop

This is the heart of the Agent, in task-loop.js. It's a while loop that continuously repeats four steps: build messages → call LLM → execute tool → handle result.

The message-building step is worth highlighting. Before every LLM call, the system rebuilds the System Prompt, dynamically injecting the current problem description and the student's latest code snapshot. This means that even if the student edits their code mid-defense, the LLM always sees the most up-to-date version on the next turn.

Single-step execution is another important constraint. To prevent the LLM from chaotically calling multiple tools at once, the engine enforces single-step mode—if the LLM returns multiple tool_calls, only the first is executed, and the LLM is warned in the next prompt.

Asynchronous suspension and resumption is the most elegant part of the entire implementation. When askStudent returns waitForReply: true, the loop doesn't exit. Instead, it awaits a pending Promise and saves the resolve function to an external variable called askResolver:

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);
}

When the user clicks "Send" in the UI, the frontend calls agent.submitStudentMessage(text), which internally calls askResolver(text). The suspended await instantly unblocks, and the loop continues with the student's reply in hand.

No magic—just plain, native JavaScript asynchronous primitives.

Multi-layered safeguards ensure the Agent doesn't run amok in production:

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

Closing Thoughts

After walking through this implementation, you'll find there's no dark magic behind AI Agents.

A while loop, a pause/resume mechanism powered by a Promise, and a unified data contract for tools are entirely sufficient to support a complete, deeply customized business workflow.

Agent frameworks solve the problem of "how to orchestrate complex Agents." They don't solve "how to elegantly embed an Agent into an existing system." When your main product is a traditional web application and AI is only used to enhance one specific step, deeply understanding your business requirements and using the simplest possible code to decouple AI capabilities from business logic will almost always produce a more robust and maintainable system than dragging in a heavyweight framework.

The best architecture is always the one that fits the business best.

Some rights reserved
Except where otherwise noted, content on this page is licensed under a Creative Commons Attribution-NonCommercial 4.0 International license.