How to Build a Cursor-Like AI Coding Agent — Complete Guide
Cursor Agent mode, Claude Code, and Windsurf share the same core pattern: an LLM in a tool-calling loop that reads a real codebase, edits files, runs shell commands, and streams progress back to an IDE. You do not need LangChain, a vector database, or a cloud orchestration platform to build one.
I built LiveCode — a self-hosted browser IDE with Monaco, an integrated terminal, and a full agent harness — entirely in Python and vanilla JavaScript. Runtime data lives in ~/livecode/; source code is in the livecode-ai repo. This guide walks through every layer with architecture diagrams, comparison tables, and pseudocode you can adapt to ship your own Cursor-like agent.
Who is this for? Engineers who want to understand how Cursor-style agentic coding works under the hood — and who want a step-by-step blueprint, not just a conceptual overview.
What makes an agent Cursor-like
A Cursor-like coding agent is not a chatbot that returns code blocks. It is a closed loop between an LLM and a filesystem on disk:
| Capability | Why it matters |
|---|---|
| Tool calling | The model decides when to grep, read, edit, or run commands — not the UI |
| Iteration loop | One user message may require 5–50 tool rounds before the task is done |
| Streaming progress | Users see thinking, tool activity, diffs, and shell output in real time |
| Context management | Long sessions exceed the context window; compaction is mandatory |
| Permission gates | Destructive shell commands require explicit user approval |
| Modes | Agent (edit), Plan (draft only), Ask (read-only) filter available tools |
| Project rules | AGENTS.md, .cursor/rules, and similar files steer behavior |
The minimum viable stack: one HTTP endpoint, one Python generator function, a tool dispatch table, and JSONL session files on disk.
Architecture at a glance
LiveCode splits into a Python backend (livecode-ai) and a browser frontend. Persistent state lives in ~/livecode/ — settings, sessions, indexes, and memory — separate from the source repo.
Browser UI talks to Flask over SSE (turn lifecycle) and Socket.IO (structured progress) in parallel
livecode-ai/ ~/livecode/ (runtime)
├── src/livecode/
│ ├── server.py ├── settings.json
│ ├── harness/turn.py └── projects/{slug}/
│ ├── tools.py ├── sessions/{id}/chat_history.jsonl
│ ├── runtime.py ├── index/workspace.json
│ ├── session.py ├── symbols/symbols.json
│ └── memory/ └── memory/index.sqlite
├── templates/index.html
└── static/js/bundle.js
One turn: classification, iteration loop, tool dispatch, and dual-channel streaming back to the browser
Phase 1 — HTTP endpoint and SSE streaming
The turn endpoint accepts a POST with the user message, project path, session ID, and mode. It returns text/event-stream — a single long-lived HTTP connection that multiplexes progress events, command output, and the final done payload.
@app.post("/livecode-agent")
def agent_turn():
body = request.get_json()
question = body["question"]
project_path = body["project_path"]
session_id = body.get("session_id") or uuid.uuid4().hex
mode = body.get("mode", "agent")
def generate():
bridge = SSEProgressBridge(socketio=socketio, room=socket_id)
chunk_queue = queue.Queue()
turn_thread = threading.Thread(
target=lambda: run_turn_in_queue(run_livecode_turn(...), chunk_queue),
daemon=True,
)
turn_thread.start()
while not turn_finished:
yield from bridge.drain_as_sse()
try:
kind, payload = chunk_queue.get(timeout=0.05)
except queue.Empty:
continue
if payload.get("done"):
append_turn_messages(project_path, session_id, payload["turn_messages"])
yield f"data: {json.dumps(payload)}\n\n"
break
yield payload
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
Why not EventSource? The browser's built-in EventSource only supports GET. The frontend uses fetch() + ReadableStream, decodes SSE data: lines manually, and handles progress, command output, and the final done payload in one loop.
| Endpoint | Method | Purpose |
|---|---|---|
/livecode-agent | POST | Main turn — returns text/event-stream |
/livecode/permission | POST | Approve or deny a sensitive tool call |
/livecode/interject | POST | Inject a mid-turn user message |
/livecode/sessions | GET | List saved sessions for a project |
/settings | GET/POST | LLM provider API keys (stored in ~/livecode/settings.json) |
Phase 2 — The agent harness loop
The harness is a Python generator — run_livecode_turn() in harness/turn.py (~2,400 lines) — that yields SSE chunks and emits Socket.IO events. Up to 100 iterations per user message.
def run_livecode_turn(project_path, question, *, mode="agent", session_id, ...):
classification = classify_turn(question)
messages = build_base_messages(project_path, question, session_id)
tools = filter_tools_for_mode(get_livecode_tools(), mode)
for iteration in range(1, LIVECODE_MAX_ITERATIONS + 1):
if stationarity_detected(tool_history):
messages.append(nudge("same tool+args repeated — try a different approach"))
if search_scatter_detected(tool_history):
messages.append(nudge("broaden grep — use glob or alternation"))
if exploration_streak_without_edit(tool_history):
messages.append(nudge("you have enough context — edit or finish"))
messages = compact_stale_tool_messages(messages, keep_last=6)
if interjection := drain_interjection(session_id):
messages.append(format_interjection(interjection))
iter_tools = tools
if iteration > LIVECODE_MAX_ITERATIONS - CLOSURE_ITERATIONS:
iter_tools = attempt_completion_only_tools(tools)
response = call_with_tools(
model=pick_auto_model(classification),
messages=messages,
tools=iter_tools,
on_thought_delta=lambda d: emit("agent_thinking_delta", d),
)
if not response.tool_calls:
yield sse_done(answer=response.content)
return
for result in execute_tool_calls_batch(response.tool_calls):
messages.append(assistant_tool_message(response))
messages.append(tool_result_message(result))
emit_progress(result)
if result.tool == "attempt_completion":
if goal_verifier_passed(question, tool_history):
yield sse_done(answer=result.summary)
return
messages.append(nudge("user asked for code change but no edit succeeded"))
yield sse_done(answer=summarize_exhaustion(messages))
Anti-loop guardrails
These are the difference between a demo and something you can leave running on a real repo:
| Pattern | Trigger | Action |
|---|---|---|
| Stationarity | Same tool + identical args 8× | Inject nudge; hard stop at 16× |
| Search scatter | 6+ narrow greps hunting one thing | Nudge to broaden (glob, regex alternation) |
| Directory drill | Sequential list_repo_dir | Nudge to use glob_files / find_files |
| Exploration streak | 8 read-only steps, no edit | Nudge to edit or attempt_completion |
| Edit no-match | edit_file old_string not found | Nudge to re-read file |
| Iteration budget | 75%, 90%, 95% of max iterations | Escalating urgency nudges |
| Goal verifier | attempt_completion but no successful edit | Reject completion when user asked for code change |
LIVECODE_MAX_ITERATIONS = 100
LIVECODE_CONTEXT_WINDOW = 128_000
STATIONARITY_NUDGE_AFTER = 8
STATIONARITY_HARD_STOP = 16
CLOSURE_ITERATIONS = 2
Per-iteration flow: nudges, compaction, LLM call, tool dispatch, and goal verification
Phase 3 — Tool system design
Tools use OpenAI function-calling format. Gemini receives the same schemas translated to functionDeclarations in runtime.py.
| Category | Tools |
|---|---|
| Search / read | grep_repo, read_repo_file, list_repo_dir, glob_files, find_files, git_log, ast_symbols |
| Symbol index | find_symbol, find_references, list_symbols |
| Edit | write_file, edit_file (exact search-replace) |
| Shell | run_command (PTY streaming) |
| Memory | update_memory, memory_search, memory_get |
| Delegation | spawn_subagent |
| Web (optional) | web_search, web_fetch |
| Plan mode | create_plan |
| Termination | attempt_completion |
def dispatch_tool(project_path, name, args, *, mode, session_id, ...):
if mode_blocks_tool(mode, name):
return {"error": mode_rejection_message(mode, name)}
if name == "run_command" and requires_permission(args["command"]):
request_id = create_permission_request(session_id, name, args)
emit_permission_prompt(request_id, args)
approved = wait_for_permission(request_id)
if not approved:
return {"error": "User denied command"}
if name == "grep_repo":
return repo_grep_fn(project_path, pattern=args["pattern"], ...)
if name == "edit_file":
return apply_search_replace(
project_path, args["file_path"],
old=args["old_string"], new=args["new_string"],
)
Batch execution rules
- Read-only tools run in parallel via
ThreadPoolExecutor - Same-file
edit_filecalls are coalesced into one search-replace pass - Mutating tools run serially after reads complete
- Tool results are truncated before returning to the LLM (grep → 30 matches, reads → 16k chars)
Why exact-match edit_file? Ambiguous search-replace (multiple occurrences of old_string) is worse than a retry. LiveCode rejects edits where old_string matches more than once — forcing the model to include more surrounding context, exactly like Cursor's apply model.
Phase 4 — Context compaction (three tiers)
Long agent sessions blow past 128k tokens quickly. LiveCode uses three compaction tiers:
| Tier | Trigger | Strategy |
|---|---|---|
| Intra-turn | >70% of context window during a turn | compact_stale_tool_messages() — truncate old tool results, keep last 6 |
| Inter-turn | >65% between turns | maybe_inter_turn_compact() — shrink before next user message |
| Session (full-replace) | >85% or forced | LLM summarizes entire prefix → replaces old messages with one summary block |
LIVECODE_AUTO_COMPACT_RATIO = 0.85
LIVECODE_IN_TURN_COMPACT_RATIO = 0.70
LIVECODE_INTER_COMPACT_RATIO = 0.65
LIVECODE_KEEP_RECENT_TOOL_MSGS = 6
Before full-replace compaction, a memory flush runs: important facts are written to MEMORY.md and indexed in SQLite (FTS + local embeddings) so they survive summarization.
Phase 5 — Dual-channel real-time UI
| Channel | Carries | Why |
|---|---|---|
| SSE (HTTP POST response) | progress, command_stream, done, answer | Simple unidirectional pipe; works through proxies |
| Socket.IO (WebSocket) | Tool chips, diff blocks, permission modals, terminal PTY | Rich JSON payloads, bidirectional |
const resp = await fetch("/livecode-agent", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
body: JSON.stringify({ project_path, question, session_id, mode }),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
for (const line of buffer.split("\n")) {
if (!line.startsWith("data: ")) continue;
const payload = JSON.parse(line.slice(6));
if (payload.progress) handleProgress(payload.progress);
if (payload.command_stream) appendTerminalOutput(payload.command_stream);
if (payload.done) finalizeTurn(payload);
}
}
Phase 6 — Permissions and safety
Destructive shell patterns (git reset --hard, rm -rf, force push) trigger a blocking permission gate:
def create_permission_request(session_id, tool_name, tool_args, timeout_s=120):
request_id = f"perm_{uuid.uuid4().hex[:16]}"
event = threading.Event()
_PERMISSIONS[request_id] = {
"session_id": session_id,
"tool_name": tool_name,
"tool_args": tool_args,
"event": event,
"approved": None,
}
return request_id
def wait_for_permission(request_id) -> bool | None:
entry = _PERMISSIONS[request_id]
if not entry["event"].wait(timeout=entry["timeout_s"]):
return None
return bool(entry["approved"])
The frontend shows a modal; the user POSTs to /livecode/permission with {request_id, approved}; resolve_permission() calls event.set() and unblocks the harness thread. All file operations go through resolve_safe_path() — no traversal outside the project root.
Phase 7 — Agent, Plan, and Ask modes
LIVECODE_MODES = ("agent", "plan", "ask")
def filter_tools_for_mode(tools, mode):
if mode == "agent":
return tools
allowed = READ_ONLY_TOOLS | {"attempt_completion"}
filtered = [t for t in tools if t["function"]["name"] in allowed]
if mode == "plan":
filtered.append(CREATE_PLAN_TOOL)
return filtered
| Mode | Can edit files | Can run shell | Special tool |
|---|---|---|---|
| Agent | Yes | Yes (with permission) | — |
| Plan | No | No | create_plan → writes markdown plan |
| Ask | No | No | — |
Phase 8 — Subagents and delegation
spawn_subagent launches a nested mini-loop with its own ephemeral session — useful for "explore the codebase and report back" without polluting the main context.
def run_subagent_turn(project_path, goal, parent_session_id, read_only=True, max_iterations=5):
child_session = f"{parent_session_id}_sub_{uuid.uuid4().hex[:8]}"
question = (
f"[Subagent — read_only={read_only}]\n{goal}\n"
"Call attempt_completion when done."
)
for chunk in run_livecode_turn(
project_path, question,
session_id=child_session,
max_iterations=max_iterations,
mode="ask" if read_only else "agent",
):
collect_answer_from_sse_chunk(chunk)
return {"success": True, "result": answer, "child_session_id": child_session}
Phase 9 — Sessions, memory, and rules
JSONL session persistence
Each turn appends to chat_history.jsonl under ~/livecode/projects/{slug}/sessions/{id}/:
{"role":"user","content":"Add health check endpoint","ts":1725000000}
{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","function":{"name":"grep_repo","arguments":"{\"pattern\":\"router\"}"}}]}
{"role":"tool","tool_call_id":"call_1","content":"{\"matches\":[{\"file\":\"app.py\",\"line\":12}]}"}
Intelligent classifier
Before the loop, a fast model classifies the turn to drive model routing:
class IntelligentClassification(TypedDict):
goal_kind: str # code_change, analysis, research, meta
edit_scope: str # none, single_line, single_file, multi_file, bulk
needs_flagship_model: bool
complexity: str # simple, medium, complex
expects_multi_step: bool
Project rules discovery
RULES_FILES = ["AGENTS.md", "CLAUDE.md", ".cursor/rules", ".livecode/rules"]
def discover_project_rules(project_path):
for directory in path_chain(git_root, project_path):
for rules_file in RULES_FILES:
if exists(join(directory, rules_file)):
found.append(read(candidate))
return wrap_in_system_reminder(found)
Injected as a follow-up system message — same convention Cursor and Claude Code use for AGENTS.md and .cursor/rules.
Phase 10 — Frontend IDE shell
The browser loads a single-page IDE with no npm build step — one bundle.js (~9k lines) served from Flask static assets:
- Left: file tree (Socket.IO file browser)
- Center: Monaco Editor 0.45 (multi-tab, syntax highlighting)
- Bottom: xterm.js terminal (PTY via Socket.IO)
- Right: agent chat (tool activity chips, inline diffs, permission modals)
Quick start: pip install -e ".[dev]" && python -m livecode → http://127.0.0.1:5050/
LiveCode vs Cursor
| Aspect | LiveCode (self-hosted) | Cursor |
|---|---|---|
| Agent loop | Python harness, 100 iter max, rich nudges | Proprietary cloud harness, skills, subagents |
| Tools | ~18 built-in, hardcoded schemas | Built-in + MCP dynamic namespaces |
| MCP | Flag exists, not implemented yet | First-class GetDynamicTools / CallDynamicTool |
| Streaming | SSE + Socket.IO dual channel | SSE throughout, rich step timeline |
| Modes | Agent / Plan / Ask | Agent / Plan / Ask / Debug + skills |
| Context | 128k, 3-tier compaction, SQLite memory | Larger windows, cloud compaction, @ attachments |
| Permissions | In-app approve/deny modal | Smart mode approval cards, sandbox |
| Subagents | spawn_subagent, max 5 iter | Task tool with specialized subagents |
| IDE | Browser Monaco + terminal | VS Code fork, deep LSP integration |
| Deployment | Local Flask :5050 | Cloud + desktop app |
Main gap vs Cursor: MCP. Dynamic tool discovery from external servers is the architectural piece LiveCode does not have yet. The integration point is get_livecode_tools() — append dynamically discovered MCP tool schemas alongside the built-in set.
Metrics and charts
Implementation checklist
Use this as a build order for your own Cursor-like agent:
- Flask route returning SSE from a generator
- OpenAI/Gemini
call_with_tools()adapter with streaming thoughts - Tool schemas:
grep,read,edit_file,run_command,attempt_completion dispatch_tool()with path safety and result truncation- Harness loop with iteration cap and stationarity detection
- JSONL session persistence
- Intra-turn tool message compaction
- Permission gate for destructive shell commands
- Socket.IO progress events + frontend activity feed
- Mode filtering (Agent / Ask at minimum)
- Project rules discovery (
AGENTS.md,.cursor/rules) - Full-replace LLM compaction at 85% threshold
- Subagent delegation (optional)
- Monaco + terminal IDE shell (optional but expected)
Reference implementation: github.com/amarkum/livecode-ai
FAQ
Do you need LangChain to build a coding agent?
No. A generator function, OpenAI tool schemas, JSONL session files, and tiered context management are sufficient for production-quality agentic coding.
Why SSE and WebSocket together?
SSE streams the turn lifecycle over a single HTTP POST. WebSocket carries structured tool progress, diffs, permission modals, and terminal PTY output — payloads that would be painful to encode in a flat text stream.
How do you stop infinite tool loops?
Stationarity detection on identical tool-call fingerprints, escalating nudges at 75%/90%/95% iteration budget, a hard 100-iteration cap, and forced attempt_completion-only tools in the last 2 iterations.
How is this different from the Cursor desktop app?
Same agent pattern — tools, iteration loop, streaming UI. LiveCode is self-hosted: you control tools, permissions, persistence format, model routing, and deployment. Cursor adds MCP, cloud orchestration, LSP integration, and a VS Code fork.
What about vector search / RAG for the codebase?
LiveCode uses ripgrep, a symbol index (Python AST + JS/TS regex), and targeted file reads instead of embeddings. For most coding tasks, grep + read beats semantic search on stale indexes — and it is vastly simpler to operate.
Can I add MCP later?
Yes. The enable_mcp flag exists in LiveCode as a stub. Append dynamically discovered MCP tool schemas in get_livecode_tools() and route dispatch_tool() calls through an MCP client.
Related guides
- How I Built a Live-Browser AI Coding Agent with Flask and Monaco
- How SSE Streaming Works in Chatbots
- SSE vs WebSocket vs REST API for Live AI
- Skills vs MCP vs Subagents
Generator loop. Tool dispatch. JSONL on disk. That is the whole trick.