How I Built a Live-Browser AI Coding Agent with Flask and Monaco

August 2026 · Published by Amar Kumar

LiveCode Agent is an in-browser AI coding agent that behaves like Cursor Agent mode or Claude Code: an LLM gets tools to read, search, edit, and run commands against a real project on disk, runs those tools in a controlled iteration loop, and streams inline diffs, tool-call activity, and shell output back to a browser UI in real time.

I built it entirely inside a Flask application with no exotic infrastructure — no vector database, no embeddings pipeline, no LangChain, no npm build step. The entire system is a Python generator function, a tool dispatch table, a JSONL file per session, and a carefully engineered context management strategy. This post walks through every major subsystem with pseudocode you can adapt.

Who is this for? Engineers who want to understand how Cursor-style agentic coding works under the hood — and who suspect you do not need a heavyweight orchestration framework to ship one.

Overview and line counts

The key claim this document makes, and backs with implementation detail: you do not need LangChain, LlamaIndex, or AutoGen to build a production-quality agentic coding system. You need:

At runtime the browser loads a single-page IDE shell: file tree on the left, Monaco editor center, xterm terminal bottom, and agent chat on the right. Every user message triggers a POST that keeps the HTTP connection open for the duration of the turn — often 30 seconds to several minutes for multi-file refactors.

The UI mirrors what Cursor users expect: a chat pane with collapsible tool-call activity chips, inline diffs with expand/collapse, a Monaco code editor, an embedded terminal, and a file explorer — all in one browser tab, backed by a project on the user's actual filesystem.

System architecture

Two halves, two channels

LiveCode Agent splits cleanly into a Python backend and a JavaScript frontend. They communicate over two simultaneous real-time channels: SSE for answer tokens, WebSocket for structured tool progress, diffs, permissions, and shell streams.

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 token · answer · done"]:::api WS["WebSocket tool · diff · permission"]:::api FL["Flask routes /livecode-agent"]:::api HN["run_livecode_turn() generator loop"]:::core TL["14 tools grep · edit · shell"]:::core LLM["Azure OpenAI tool calling"]:::core SS["JSONL sessions compaction sidecar"]:::disk IX["File + symbol 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 (answer text) and WebSocket (tool activity) in parallel

SSE (text/event-stream) streams the model's final answer text — token by token. It is a simple unidirectional pipe.

WebSocket (implemented with Socket.IO in my stack) carries everything else: tool-call progress, streamed shell output, diff blocks, permission prompts, and thinking indicators. These payloads have rich, differently-shaped JSON structures that would be painful to shoehorn into a flat SSE text stream.

Why fetch() + ReadableStream instead of EventSource

The browser's built-in EventSource API only supports GET requests. The turn endpoint needs a JSON body (user message, model selection, session ID). The frontend does a raw fetch("/livecode-agent", { method: "POST", body: JSON.stringify({...}) }) and manually reads the response as a ReadableStream, decoding with a TextDecoder and parsing data: prefixed SSE lines. Roughly 60 lines of frontend code — non-standard but straightforward.

HTTP endpoints

EndpointMethodPurpose
/livecode-agentPOSTMain turn — returns text/event-stream
/livecode/sessionsGETList saved sessions for a project
/livecode/sessionGETLoad one session's canonical history
/livecode/session/forkPOSTFork a session at its current state
/livecode/session/rewindPOSTRoll history back to message N
/livecode/session/renamePOSTRename a session
/livecode/session/deletePOSTDelete a session
/livecode/permissionPOSTApprove or deny a sensitive tool call
/livecode/interjectPOSTInject a mid-turn user message
/livecode/indexPOSTForce-rebuild the file manifest
def agent_turn():
    def generate():
        for chunk in run_livecode_turn(...):
            yield chunk  # "data: {...}\n\n"

    return Response(
        stream_with_context(generate()),
        mimetype="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )

After the generator exhausts, the route persists the turn to disk via append-only JSONL writes.

Architecture diagrams

The following diagrams show how a single user turn flows through the stack — from browser POST to tool dispatch, persistence, and streamed UI updates.

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 WebSocket U->>F: POST question + session_id F->>H: run_livecode_turn() H->>H: classify · index · compact loop up to 20 iterations H->>L: messages + tool schemas L-->>H: tool_calls or content alt tool calls H->>W: tool_call progress H->>T: dispatch parallel/serial T-->>H: tool results H->>W: diff_block if edit else plain answer H-->>F: SSE tokens F-->>U: data answer chunks 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

flowchart TD classDef step fill:#f1f5f9,stroke:#64748b,color:#334155 classDef gate fill:#fef3c7,stroke:#d97706,color:#92400e classDef end fill:#dcfce7,stroke:#16a34a,color:#14532d A([Start turn]):::step B[Stationarity check]:::gate C[In-turn compaction]:::step D[LLM call with tools]:::step E{Tool calls?}:::gate F[Dispatch tools]:::step G{attempt_completion?}:::gate H[Stream answer SSE]:::end I[Codebase recovery nudge]:::gate J[Exhaustion summarizer]:::end A --> B --> C --> D --> E E -->|yes| F --> G G -->|yes| H G -->|no| B E -->|no prose iter 1| I --> B E -->|no final text| H B -->|16 repeats| J

Harness loop with stationarity gate, in-turn compaction, tool dispatch, and graceful exhaustion exit

sequenceDiagram participant H as Harness thread participant W as WebSocket participant U as Browser UI participant P as POST /livecode/permission H->>H: sensitive tool write edit shell H->>W: permission_request W->>U: show approve deny modal H->>H: block on threading Event U->>P: approved or denied P->>H: resolve_permission H->>H: unblock and dispatch or skip

Sensitive tools block the worker thread until the user approves via REST

flowchart TB classDef file fill:#f8fafc,stroke:#475569,color:#334155 ROOT["~/.livecode/sessions/project_hash/session_id/"]:::file J["chat_history.jsonl append-only messages"]:::file S["summary.json metadata reminders"]:::file C["compaction.json boundary summary hash"]:::file CP["compaction_checkpoints/ audit trail"]:::file ROOT --> J ROOT --> S ROOT --> C ROOT --> CP

Disk-backed sessions — no database; hash-verified compaction sidecar

Metrics and charts

Illustrative breakdowns from the LiveCode Agent codebase and runtime thresholds. These numbers come from direct line counts and configured constants — not synthetic benchmarks.

Approximate size by subsystem — backend package, routes, and frontend JS

Compaction tiers on a 128k token budget — inter-turn at 65%, in-turn shrink every iteration at 70%, full-replace at 85%

Typical tool mix during multi-step coding tasks — read and grep dominate early iterations

Why two channels — SSE excels at token streaming; WebSocket fits structured JSON events

Backend package structure

The livecode/ package is 19 files. Here is every module and its responsibility:

FileLinesResponsibility
harness.py1,072Agentic tool-calling loop — messages, classification, LLM, tools, SSE, compaction, stationarity
tools.py86414 tool schemas, dispatch_tool(), implementations, result compaction, UI labels
prompts.py127System/compaction/classifier prompts and tunable constants
session.py509JSONL persistence, compaction sidecar, API sanitization, session CRUD
codebase_index.py302Python AST + JS/TS regex symbol extraction, disk cache, watchdog
workspace.py316File manifest, gitignore filtering, safe path resolution, layout tree
context.py92Facade over compaction subpackage
compaction/intra.py212In-turn dedupe and shrink ladder for stale tool messages
compaction/full_replace.py179LLM-driven full-conversation summarization
compaction/inter.py60Lighter inter-turn compaction before hard threshold
memory.py48Cross-session per-project markdown memory
routing.py177Heuristic + LLM classification, tool-choice policy
reminders.py76Session-scoped reminder state injected into system prompt
interjection.py34Thread-safe queue for mid-turn user messages
permissions.py73Blocking permission gate for sensitive tools
subagent.py61Nested child harness runs for spawn_subagent
rules.py145Discovers AGENTS.md, CLAUDE.md, .cursor/rules and injects them

Constants that drive the whole system

MAX_ITERATIONS = 20
CONTEXT_WINDOW = 128_000
AUTO_COMPACT_RATIO = 0.85       # full-replace at 108,800 tokens
INTER_COMPACT_RATIO = 0.65      # inter-turn at 83,200 tokens
IN_TURN_COMPACT_RATIO = 0.70    # in-turn budget 89,600 tokens
KEEP_RECENT_TOOL_MSGS = 6
STATIONARITY_NUDGE_AFTER = 8
STATIONARITY_HARD_STOP = 16
TOOL_RESULT_MAX_CHARS = 16_000

The agent harness loop — build guide

run_livecode_turn() in harness.py is a Python generator function. It yields raw SSE payload strings (data: {...}\n\n) directly. It never returns a value.

Turn setup (before the loop)

  1. Classify the incoming question — heuristics first, LLM fallback if inconclusive
  2. Build/refresh workspace file manifest (cheap no-op if mtime fingerprint matches cache)
  3. Build/refresh symbol index (Python AST + JS/TS regex, disk-cached)
  4. Possibly compact session if history is already near token budget
  5. Build base message list — system prompt, optional layout tree, rules, projected history, new user message

Build the harness loop — step by step

run_livecode_turn() is a Python generator: it yields SSE strings many times per user message. Below, each step shows the code to write and what every identifier means.

Step 1 — Iteration ceiling

for iteration in range(1, MAX_ITERATIONS + 1):
    # one LLM round-trip per iteration; hard cap prevents runaway token spend
SymbolMeaning
MAX_ITERATIONSHard cap (20) on LLM calls per user message
iteration1-based counter logged as iteration 3/20

Step 2 — Stationarity gate

    if stationarity.count >= STATIONARITY_HARD_STOP:
        yield sse_done("Stopped: repeated identical tool calls.")
        return
    if stationarity.count >= STATIONARITY_NUDGE_AFTER:
        messages.append({"role": "user", "content": STATIONARITY_NUDGE})

Step 3 — Model + context before each LLM call

    iteration_model = pick_model(model, classification, iteration, had_tool_errors)
    messages = compact_stale_tool_messages(messages, budget=89600)
    messages.extend(drain_interjection_queue(session_id))
    messages = sanitize_messages_for_api(messages)

Step 4 — LLM call with tools

    response = call_llm_with_tools(
        model=iteration_model,
        messages=messages,
        tools=LIVECODE_TOOLS,
        tool_choice=pick_tool_choice(classification, iteration),
    )

Step 5 — Dispatch tools or finish

    if response.tool_calls:
        results = dispatch_parallel_or_serial(response.tool_calls, session_id)
        for tool_call, result in zip(response.tool_calls, results):
            messages.append(tool_message(tool_call, compact_tool_result(result)))
        if any(tc.name == "attempt_completion" for tc in response.tool_calls):
            yield sse_done(answer=extract_completion(results), turn_messages=turn_persist)
            return
        continue

    if iteration == 1 and no_tools_used and needs_codebase_evidence(question):
        messages.append({"role": "user", "content": CODEBASE_RECOVERY_PROMPT})
        continue

    if response.content:
        yield from stream_tokens_as_sse(response.content)
        yield sse_done(answer=response.content, turn_messages=turn_persist)
        return

Step 6 — Exhaustion fallback

messages.append({"role": "user", "content": "Summarize without more tools."})
yield sse_done(answer=call_llm_no_tools(messages), turn_messages=turn_persist)

Full skeleton

def run_livecode_turn(question, project_path, session_id, model="auto"):
    messages = build_base_messages(question, project_path, session_id)
    turn_persist, stationarity = [], IdenticalToolCallRun()
    classification = classify_turn(question)
    had_tool_errors, no_tools_used = False, True

    for iteration in range(1, MAX_ITERATIONS + 1):
        if stationarity.count >= STATIONARITY_HARD_STOP:
            yield sse_done("Stopped: repeated identical tool calls.")
            return
        if stationarity.count >= STATIONARITY_NUDGE_AFTER:
            messages.append({"role": "user", "content": STATIONARITY_NUDGE})

        iteration_model = pick_model(model, classification, iteration, had_tool_errors)
        messages = compact_stale_tool_messages(messages, budget=89600)
        messages.extend(drain_interjection_queue(session_id))
        messages = sanitize_messages_for_api(messages)

        response = call_llm_with_tools(
            model=iteration_model,
            messages=messages,
            tools=LIVECODE_TOOLS,
            tool_choice=pick_tool_choice(classification, iteration),
        )

        if response.tool_calls:
            no_tools_used = False
            results = dispatch_parallel_or_serial(response.tool_calls, session_id)
            for tc, result in zip(response.tool_calls, results):
                messages.append(tool_message(tc, compact_tool_result(result)))
            if any(tc.name == "attempt_completion" for tc in response.tool_calls):
                yield sse_done(answer=extract_completion(results), turn_messages=turn_persist)
                return
            continue

        if iteration == 1 and no_tools_used and needs_codebase_evidence(question):
            messages.append({"role": "user", "content": CODEBASE_RECOVERY_PROMPT})
            continue

        if response.content:
            yield from stream_tokens_as_sse(response.content)
            yield sse_done(answer=response.content, turn_messages=turn_persist)
            return

    messages.append({"role": "user", "content": "Summarize without more tools."})
    yield sse_done(answer=call_llm_no_tools(messages), turn_messages=turn_persist)

Stationarity detection

An tracker hashes every tool call as tool_name:json(arguments) and counts consecutive repeats. At 8 identical calls: inject a nudge. At 16: force-terminate. This prevents the model from spinning on a retry loop.

Parallel tool dispatch

When the model returns multiple independent tool calls, they run concurrently but results are reassembled in original order — the OpenAI function-calling contract requires tool results in the same order as the calls. I cap workers at six to avoid stampeding the filesystem or shell on large parallel batches.

if len(tool_calls) > 1:
    with ThreadPoolExecutor(max_workers=min(len(tool_calls), 6)) as pool:
        futures = {pool.submit(execute_one, tc): i for i, tc in enumerate(tool_calls)}
        results = reassemble_in_original_order(futures)
else:
    results = [execute_one(tool_calls[0])]

Tool system

The tool schemas are standard OpenAI function-calling JSON schema objects. Fourteen core tools:

ToolCore behavior
find_symbolSubstring match on in-memory symbol table, optional kind filter
find_referencesCompiles \bname\b regex, reads indexed files line-by-line
list_symbolsFilters symbol table by file/directory prefix
grep_reporipgrep subprocess, results capped at 16,000 chars
read_repo_fileMax 300 numbered lines per call
list_repo_dirRoot path → full project tree; else directory listing
write_fileRead old → write new → generate diff
edit_fileSEARCH/REPLACE with ambiguity rejection
run_commandShell subprocess, blocklist, streamed output over WebSocket
git_logNative git log parsed to structured dicts
ast_symbolsPython AST symbol extractor for one file
update_memoryAppends markdown bullet to per-project memory file
spawn_subagentNested 5-iteration child harness run
attempt_completionSentinel tool — signals turn end with final result text

edit_file — ambiguity-rejecting SEARCH/REPLACE

One of the most important correctness guarantees in the whole system:

def edit_file(path, old_string, new_string):
    content = read_file(path)
    occurrences = content.count(old_string)

    if occurrences == 0:
        return error("old_string not found in file")

    if occurrences > 1:
        return error(
            "old_string found multiple times — too ambiguous. "
            "Provide more surrounding context to make the target unique."
        )

    new_content = content.replace(old_string, new_string, 1)
    write_file(path, new_content)
    diff_html, diff_text, adds, dels = create_diff_html(content, new_content)
    return success(diff_html=diff_html, additions=adds, deletions=dels)

An ambiguous edit is rejected rather than silently applied to the wrong occurrence — the same discipline Claude Code's Edit tool enforces.

run_command — blocklist and git-log redirect

BLOCKED_PATTERNS = ["rm -rf /", "mkfs", ":(){ :|:& };:"]

def run_command(command):
    if any(p in command for p in BLOCKED_PATTERNS):
        return error("Command blocked — destructive pattern")

    if "git log" in command:
        return error("Use the git_log tool instead of run_command for history")

    if is_quick_command(command):  # echo, pwd, ls, cat, head, tail, wc
        return subprocess.run(command, shell=True, capture_output=True, timeout=90)

    # Long-running: stream line-by-line over WebSocket
    proc = Popen(command, shell=True, stdout=PIPE, stderr=STDOUT, cwd=project_root)
    for line in proc.stdout:
        emit_websocket("command_stream", line.decode())
    # 600s timeout, output capped to last 12,000 chars

git_log — structured, not raw text

def git_log(path=None, grep=None, since=None, max_commits=80):
    cmd = ["git", "--no-pager", "log", f"-n{max_commits}",
           "--date=short", "--pretty=format:%h %ad %an %s"]
    if path: cmd += ["--", path]
    if grep: cmd += [f"--grep={grep}"]
    if since: cmd += [f"--since={since}"]

    output = subprocess.run(cmd, capture_output=True, text=True).stdout
    return [
        {"hash": m[1], "date": m[2], "author": m[3], "subject": m[4]}
        for line in output.splitlines()
        if (m := GIT_LOG_LINE_RE.match(line))
    ]

Tool result compaction and UI labels

Every tool result passes through compact_tool_result_for_llm() before being appended to the message list — hard cap 16,000 chars with tool-specific truncation strategies for file reads and grep output.

human_tool_label() converts raw tool calls into Cursor-style one-liners for the activity feed: Grepped `pattern` in `*.py`, Editing `foo.py`, Git log `src/` grep `fix`.

Codebase indexing

There are two separate indexes, built by different mechanisms, serving different tools.

File manifest

def resolve_safe_path(project_root, requested_path):
    resolved = realpath(join(project_root, requested_path))
    if not resolved.startswith(realpath(project_root)):
        raise PermissionError(f"Path traversal attempt: {requested_path}")
    return resolved

Symbol index

Python parsing uses ast.walk() for ClassDef, FunctionDef, AsyncFunctionDef. JavaScript/TypeScript uses a lightweight regex — deliberately avoiding a full TS parser dependency. Extensions: .py, .js, .jsx, .ts, .tsx, .mjs, .cjs.

Optional watchdog observer incrementally re-parses on file modify/create/delete. find_references re-reads every indexed file at call time — grep-like behavior, not true static analysis despite the name.

Session persistence

Every conversation is a directory on disk — not a database row.

~/.livecode/sessions/
└── <project_hash>/
    └── <session_id>/
        ├── chat_history.jsonl    # append-only OpenAI-format messages
        ├── summary.json            # metadata + reminder state
        ├── compaction.json         # active compaction sidecar
        └── compaction_checkpoints/ # audit trail per compaction event

What gets fed to the LLM each turn

The harness never sends raw JSONL verbatim. It calls get_projected_messages():

def get_projected_messages(session_dir):
    messages = load_jsonl("chat_history.jsonl")
    compaction = load_json("compaction.json")

    if compaction and hash_prefix(messages, compaction) == compaction["prefix_hash"]:
        summary_msg = {
            "role": "user",
            "content": f"[Previous conversation summary — continue from this context]\n\n{compaction['summary']}"
        }
        return [summary_msg] + messages[compaction["boundary_index"]:]

    return sanitize_messages_for_api(messages)

Hash verification protects against stale summaries after session rewind — if the prefix no longer matches, fall back to full history.

Message sanitization for the OpenAI API

sanitize_messages_for_api() drops orphaned tool messages and strips unreplied tool_calls off assistant messages — without this, the API rejects malformed sequencing outright.

Context compaction — three tiers

Long agent sessions are where most agentic systems break down. LiveCode Agent uses three tiers:

flowchart LR classDef tier fill:#f1f5f9,stroke:#64748b,color:#334155 classDef hot fill:#fee2e2,stroke:#dc2626,color:#991b1b P0["0% tokens"]:::tier P65["65% inter-turn summarize older half"]:::tier P70["70% in-turn shrink every iteration"]:::tier P85["85% full-replace LLM 7-section summary"]:::hot P100["100% context limit"]:::hot P0 --> P65 --> P70 --> P85 --> P100

Three compaction tiers — cheap passes first, LLM summarization only at the hard threshold

Tier 1: In-turn compaction (no LLM)

Runs every iteration unconditionally. Budget: 89,600 tokens. Pass 1 dedupes stale file reads and grep results — keep only the most recent. Pass 2 degrades all but the last 6 tool messages through fitted (2k chars) → lossy (stub JSON) shrink tiers.

Tier 2: Inter-turn compaction (65–85%)

Fires between turns when estimated tokens cross 83,200 but stay below 108,800, and history has at least 10 messages. Summarizes the older half; keeps the newer half verbatim.

Tier 3: Full-replace compaction (≥85%)

LLM-driven summarization with a seven-section prompt: Primary Request and Intent; Key Technical Concepts; Tool Usage and Verification; Files and Code Artifacts; Errors and Fixes; Problem Solving; All User Messages. Keeps the last 2–6 messages verbatim. Stores prefix_hash for staleness detection. Retries with a shorter prompt if the summary is degenerate (too short, repetitive, or template-echo).

Token estimation

def estimate_tokens(messages):
    total_chars = sum(len(json.dumps(m)) for m in messages)
    return total_chars // 4  # ~4 chars per token — cheap and conservative

Memory system

Cross-session memory is intentionally simple — no embeddings.

~/.livecode/memory/<project_hash>.md
MAX_MEMORY_READ = 2_048   # chars returned to system prompt
MAX_MEMORY_STORE = 6_144  # chars kept on disk (3x read window)

def load_project_memory(project_path):
    content = read_memory_file(project_path)
    return content[-MAX_MEMORY_READ:]  # recency-biased

def append_project_memory(project_path, note):
    existing = read_memory_file(project_path)
    new_content = existing + f"\n- {note}"
    if len(new_content) > MAX_MEMORY_STORE:
        new_content = new_content[-MAX_MEMORY_STORE:]
    write_memory_file(project_path, new_content)

Once a session has been compacted, the harness switches to a terser compaction-specific system prompt that drops the memory block — trading a small amount of continuity for a leaner prompt.

Request classification and routing

Before the agent loop starts, every question is classified:

{
  "is_meta": False,
  "is_actionable": True,
  "needs_shell": True,
  "chat_only": False,
  "expects_multi_step": True,
  "complexity": "medium",
  "is_follow_up": False
}

Heuristics first; cheap LLM call if confidence < 0.7.

def pick_tool_choice(classification, iteration):
    if classification["chat_only"] or classification["is_meta"]:
        return "auto"
    if iteration == 1 and classification["is_actionable"]:
        return "required"  # force a tool call on first iteration
    return "auto"

needs_codebase_evidence() regex-matches questions like "how does X work", "where is Y", "explain this code". If iteration 1 returns plain prose with no tools, a recovery nudge fires — preventing hallucinated answers about code the model never read.

Permission and safety system

Three tools require explicit user approval: write_file, edit_file, run_command.

def request_permission(session_id, tool_name, arguments):
    event = threading.Event()
    _pending[session_id] = event

    emit_websocket("permission_request", tool=tool_name, arguments=arguments)

    event.wait(timeout=300)  # 5-minute timeout
    return _decisions.get(session_id, False)

def resolve_permission(session_id, approved):
    _decisions[session_id] = approved
    _pending[session_id].set()

The harness worker thread blocks on a threading.Event until the browser calls POST /livecode/permission.

Sub-agent architecture

def spawn_subagent(instruction, parent_session_id, project_path):
    child_session_id = f"{parent_session_id}_sub_{uuid4().hex[:8]}"
    result = run_subagent_turn(
        instruction=instruction,
        project_path=project_path,
        session_id=child_session_id,
        max_iterations=5,  # vs 20 for parent
    )
    return {"result": result, "child_session_id": child_session_id}

Child sessions are ephemeral — not shown in the session list. The parent only sees the final synthesized result; intermediate child tool calls never pollute parent context.

Real-time communication

SSE stream — final answer text

data: {"type": "token", "content": "I'll"}\n\n
data: {"type": "token", "content": " start"}\n\n
data: {"type": "answer", "content": "I'll start by grepping..."}\n\n
data: {"type": "done", "answer": "...", "turn_messages": [...]}\n\n

Frontend SSE consumer

const response = await fetch("/livecode-agent", {
  method: "POST",
  body: JSON.stringify({ question, session_id, model }),
  headers: { "Content-Type": "application/json" },
});

const reader = response.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 });
  const lines = buffer.split("\n\n");
  buffer = lines.pop();
  for (const line of lines) {
    if (line.startsWith("data: ")) {
      handleSSEEvent(JSON.parse(line.slice(6)));
    }
  }
}

WebSocket progress events

socket.on("livecode_progress", (event) => {
  switch (event.type) {
    case "agent_thinking": showThinkingIndicator(); break;
    case "tool_call": appendActivityChip(event.label, "running"); break;
    case "tool_result": finalizeActivityChip(event.tool_name); break;
    case "diff_block": renderDiffBlock(event.diff_html); break;
    case "permission_request": showPermissionPrompt(event); break;
    case "compaction": showCompactionNotice(); break;
    case "model_resolved": updateModelBadge(event.model); break;
  }
});

Mid-turn interjection

While the agent loop runs, the user can POST a follow-up message to /livecode/interject. A thread-safe queue drains at the start of every iteration before the next LLM call — so you can redirect a long-running task without waiting for all 20 iterations to finish.

Activity chip state machine

Tool progress uses a simple three-state UI model: running (spinner + human label from human_tool_label()), success (checkmark), or error (red badge). Chips collapse into a single row per iteration, matching Cursor's activity feed density.

Frontend architecture

livecode.js — 3,658 lines, plain JavaScript, no build step, no npm. Global functions and global state. Major clusters:

Monaco integration (pseudocode)

require.config({ paths: { vs: "/static/monaco/vs" } });
require(["vs/editor/editor.main"], function() {
  window.ideEditor = monaco.editor.create(document.getElementById("ide-monaco"), {
    value: "",
    language: "python",
    theme: currentTheme,
    automaticLayout: true,
    minimap: { enabled: false },
  });
});

function mapToMonacoLang(filename) {
  const ext = filename.split(".").pop();
  const MAP = { py: "python", js: "javascript", ts: "typescript",
                jsx: "javascript", tsx: "typescript", md: "markdown" };
  return MAP[ext] || "plaintext";
}

Terminal integration (pseudocode)

function createTerminalTab(name) {
  const term = new Terminal({
    cursorBlink: true,
    fontFamily: "JetBrains Mono, monospace",
    fontSize: 13,
  });
  term.open(document.getElementById("ide-terminal-container"));
  socket.on("terminal_output", (data) => {
    if (data.tab_id === tabId) term.write(data.content);
  });
}

Diff generation

def create_diff_html(original_content, new_content, context_lines=2):
    original_lines = original_content.splitlines(keepends=True)
    new_lines = new_content.splitlines(keepends=True)

    matcher = difflib.SequenceMatcher(
        None, original_lines, new_lines, autojunk=False
    )
    opcodes = matcher.get_opcodes()
    change_blocks = group_into_contiguous_blocks(opcodes, context_lines)

    diff_html_parts = [render_block_html(b) for b in change_blocks]

    unified = difflib.unified_diff(original_lines, new_lines, lineterm="")
    diff_text = "".join(unified)
    additions = count_plus_lines(diff_text)
    deletions = count_minus_lines(diff_text)

    return "".join(diff_html_parts), diff_text, additions, deletions

Each line renders with gutter bar, line number, +/- sign, and content span. data-start-line and data-end-line on block wrappers power "jump to line" links in the diff header.

Small diffs (≤ 8 changed lines) render expanded by default. Larger diffs collapse behind a chevron toggle bound via event delegation — not re-bound per diff block.

Frontend diff block builder (JavaScript)

The browser wraps server-generated diff HTML in a collapsible card with filename, +/- counts, and a copy button:

const DIFF_SMALL_LINE_THRESHOLD = 8;

function buildDiffBlockHtml(diffEvent) {
  const { diff_html, diff_text, additions, deletions, file_path } = diffEvent;
  const changedLines = additions + deletions;
  const isSmall = changedLines <= DIFF_SMALL_LINE_THRESHOLD;

  return `
    <div class="livecode-diff-block">
      <div class="diff-header">
        ${file_path}
        +${additions}
        -${deletions}
        ${isSmall ? "" : '<button class="diff-chevron-toggle">▼</button>'}
        <button class="diff-copy-btn" data-diff="${escapeHtml(diff_text)}">Copy</button>
      </div>
      <div class="diff-content ${isSmall ? "is-expanded" : "is-collapsed"}">
        ${diff_html}
      </div>
    </div>`;
}

document.addEventListener("click", (event) => {
  if (!event.target.matches(".diff-chevron-toggle")) return;
  const content = event.target.closest(".livecode-diff-block").querySelector(".diff-content");
  content.classList.toggle("is-expanded");
  content.classList.toggle("is-collapsed");
});

Project rules discovery

RULES_FILES = ["AGENTS.md", "CLAUDE.md", ".cursor/rules", ".claude/rules"]

def discover_project_rules(project_path):
    git_root = find_git_root(project_path)
    search_dirs = build_path_chain(git_root, project_path)

    found_content = []
    for directory in search_dirs:
        for rules_file in RULES_FILES:
            candidate = join(directory, rules_file)
            if exists(candidate):
                found_content.append(f"# {rules_file}\n{read(candidate).strip()}")

    if not found_content:
        return None

    combined = "\n\n".join(found_content)
    return f"<system-reminder>\n{combined}\n</system-reminder>"

Injected as a follow-up system message after the main system prompt — the same convention Claude Code uses for CLAUDE.md.

Design decisions and tradeoffs

  1. Two real-time channels — SSE for answer text; WebSocket for structured progress. Parsing complex events from a flat SSE stream would complicate the frontend unnecessarily.
  2. Three compaction tiers — cheap non-LLM intra-turn shrink runs every iteration; inter-turn catches sessions at 65%; full-replace LLM summarization only at 85%.
  3. Hash-verified compaction — stale summaries discarded after rewind or mutation.
  4. Ambiguity-rejecting file edits — wrong-occurrence edits are worse than asking the model to retry with more context.
  5. Hard iteration ceiling with graceful exit — 20 iterations max, but exhaustion always triggers a final summarization pass.
  6. Recency-biased memory — flat markdown, last N characters, no embeddings.
  7. No orchestration framework — generator + dispatch table + JSONL is debuggable from browser event to exact Python line.

Summary

ComponentImplementationKey detail
Agent loopPython generator20-iteration cap, stationarity detection, codebase-evidence recovery
Tool callingOpenAI function calling, 14 toolsParallel dispatch, permission gate for sensitive tools
File editingSEARCH/REPLACERejects ambiguous matches (>1 occurrence)
Context compactionThree-tierLLM summarization only at 85% threshold
Session persistenceJSONL + sidecar JSONHash-verified compaction validity
Symbol indexingPython AST + JS/TS regexPer-project singleton, optional watchdog
MemoryFlat markdown per projectLast 2,048 chars read, no embeddings
Real-timeSSE + WebSocketTwo channels, clean separation
Diff generationdifflib.SequenceMatcherautojunk=False, 2-line context, small-diff auto-expand
Permissionsthreading.Event gateFrontend unblocks worker via POST
Sub-agentsNested harness, max 5 iterationsEphemeral child session, single result to parent
Project rulesAGENTS.md / CLAUDE.md walkFollow-up system message injection

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 works well for token streaming over HTTP POST. WebSocket fits structured tool progress, inline diffs, permission modals, and shell streams without encoding everything into a text event stream.

How do you stop infinite tool loops?

Stationarity detection (identical call fingerprints), a 20-iteration hard cap, and a forced summarization pass when the cap is hit.

How is this different from Cursor?

Same agent pattern — tools, iteration loop, streaming UI. LiveCode Agent is self-hosted in your Flask app with full control over tools, permissions, persistence format, and model routing.

What about vector search / RAG for the codebase?

LiveCode Agent deliberately uses ripgrep, symbol index, and file reads instead of embeddings. For most coding tasks, targeted grep + read beats semantic search on stale indexes — and it is vastly simpler to operate.

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