How to Build a Cursor-Like AI Coding Agent — Complete Guide

August 2026 · Published by Amar Kumar

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:

CapabilityWhy it matters
Tool callingThe model decides when to grep, read, edit, or run commands — not the UI
Iteration loopOne user message may require 5–50 tool rounds before the task is done
Streaming progressUsers see thinking, tool activity, diffs, and shell output in real time
Context managementLong sessions exceed the context window; compaction is mandatory
Permission gatesDestructive shell commands require explicit user approval
ModesAgent (edit), Plan (draft only), Ask (read-only) filter available tools
Project rulesAGENTS.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.

flowchart LR classDef ui fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef api fill:#ede9fe,stroke:#7c3aed,color:#5b21b6 classDef core fill:#f1f5f9,stroke:#64748b,color:#334155 classDef disk fill:#ccfbf1,stroke:#0d9488,color:#115e59 BR["Browser IDE Monaco · xterm · chat"]:::ui SSE["SSE stream progress · done"]:::api WS["Socket.IO tool · diff · permission"]:::api FL["Flask /livecode-agent"]:::api HN["run_livecode_turn() 100-iter generator"]:::core TL["18 tools grep · edit · shell"]:::core LLM["OpenAI / Gemini function calling"]:::core SS["JSONL sessions compaction sidecar"]:::disk IX["Symbol + workspace index cache"]:::disk BR -->|"fetch POST"| FL FL --> HN HN --> LLM HN --> TL HN --> SS HN --> IX HN -->|"yield SSE"| SSE SSE --> BR HN -->|"emit events"| WS WS --> BR

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
sequenceDiagram autonumber participant U as Browser participant F as Flask /livecode-agent participant H as Harness loop participant L as LLM participant T as Tools participant W as Socket.IO U->>F: POST question + session_id + mode F->>H: run_livecode_turn() in thread H->>H: classify · index · compact loop up to 100 iterations H->>L: messages + tool schemas L-->>H: tool_calls or content alt tool calls H->>W: tool_call progress H->>T: dispatch batch parallel/serial T-->>H: tool results H->>W: diff_block if edit else plain answer H-->>F: SSE done payload F-->>U: data done answer end end H->>H: append JSONL H-->>F: SSE done F-->>U: turn complete

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.

EndpointMethodPurpose
/livecode-agentPOSTMain turn — returns text/event-stream
/livecode/permissionPOSTApprove or deny a sensitive tool call
/livecode/interjectPOSTInject a mid-turn user message
/livecode/sessionsGETList saved sessions for a project
/settingsGET/POSTLLM provider API keys (stored in ~/livecode/settings.json)

Phase 2 — The agent harness loop

The harness is a Python generatorrun_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:

PatternTriggerAction
StationaritySame tool + identical args 8×Inject nudge; hard stop at 16×
Search scatter6+ narrow greps hunting one thingNudge to broaden (glob, regex alternation)
Directory drillSequential list_repo_dirNudge to use glob_files / find_files
Exploration streak8 read-only steps, no editNudge to edit or attempt_completion
Edit no-matchedit_file old_string not foundNudge to re-read file
Iteration budget75%, 90%, 95% of max iterationsEscalating urgency nudges
Goal verifierattempt_completion but no successful editReject 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
flowchart TD classDef step fill:#f1f5f9,stroke:#64748b,color:#334155 classDef gate fill:#fef3c7,stroke:#d97706,color:#92400e classDef finish fill:#dcfce7,stroke:#16a34a,color:#14532d A([Start turn]):::step B[Anti-loop nudges]:::gate C[Intra-turn compaction]:::step D[LLM call with tools]:::step E{Tool calls?}:::gate F[Dispatch tools batch]:::step G{attempt_completion?}:::gate H[Stream answer SSE]:::finish I[Goal verifier reject]:::gate J[Exhaustion summarizer]:::finish A --> B --> C --> D --> E E -->|yes| F --> G G -->|pass| H G -->|reject| I --> B G -->|no| B E -->|no| H B -->|100 iter exhausted| J

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.

CategoryTools
Search / readgrep_repo, read_repo_file, list_repo_dir, glob_files, find_files, git_log, ast_symbols
Symbol indexfind_symbol, find_references, list_symbols
Editwrite_file, edit_file (exact search-replace)
Shellrun_command (PTY streaming)
Memoryupdate_memory, memory_search, memory_get
Delegationspawn_subagent
Web (optional)web_search, web_fetch
Plan modecreate_plan
Terminationattempt_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

  1. Read-only tools run in parallel via ThreadPoolExecutor
  2. Same-file edit_file calls are coalesced into one search-replace pass
  3. Mutating tools run serially after reads complete
  4. 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:

TierTriggerStrategy
Intra-turn>70% of context window during a turncompact_stale_tool_messages() — truncate old tool results, keep last 6
Inter-turn>65% between turnsmaybe_inter_turn_compact() — shrink before next user message
Session (full-replace)>85% or forcedLLM 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

ChannelCarriesWhy
SSE (HTTP POST response)progress, command_stream, done, answerSimple unidirectional pipe; works through proxies
Socket.IO (WebSocket)Tool chips, diff blocks, permission modals, terminal PTYRich 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
ModeCan edit filesCan run shellSpecial tool
AgentYesYes (with permission)
PlanNoNocreate_plan → writes markdown plan
AskNoNo

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:

Quick start: pip install -e ".[dev]" && python -m livecodehttp://127.0.0.1:5050/

LiveCode vs Cursor

AspectLiveCode (self-hosted)Cursor
Agent loopPython harness, 100 iter max, rich nudgesProprietary cloud harness, skills, subagents
Tools~18 built-in, hardcoded schemasBuilt-in + MCP dynamic namespaces
MCPFlag exists, not implemented yetFirst-class GetDynamicTools / CallDynamicTool
StreamingSSE + Socket.IO dual channelSSE throughout, rich step timeline
ModesAgent / Plan / AskAgent / Plan / Ask / Debug + skills
Context128k, 3-tier compaction, SQLite memoryLarger windows, cloud compaction, @ attachments
PermissionsIn-app approve/deny modalSmart mode approval cards, sandbox
Subagentsspawn_subagent, max 5 iterTask tool with specialized subagents
IDEBrowser Monaco + terminalVS Code fork, deep LSP integration
DeploymentLocal Flask :5050Cloud + 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:

  1. Flask route returning SSE from a generator
  2. OpenAI/Gemini call_with_tools() adapter with streaming thoughts
  3. Tool schemas: grep, read, edit_file, run_command, attempt_completion
  4. dispatch_tool() with path safety and result truncation
  5. Harness loop with iteration cap and stationarity detection
  6. JSONL session persistence
  7. Intra-turn tool message compaction
  8. Permission gate for destructive shell commands
  9. Socket.IO progress events + frontend activity feed
  10. Mode filtering (Agent / Ask at minimum)
  11. Project rules discovery (AGENTS.md, .cursor/rules)
  12. Full-replace LLM compaction at 85% threshold
  13. Subagent delegation (optional)
  14. 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.

Generator loop. Tool dispatch. JSONL on disk. That is the whole trick.