How I Built a Live-Browser AI Coding Agent with Flask and Monaco
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:
- A Python package (
livecode/) — agent loop, tools, sessions, compaction — roughly 4,376 lines across 19 files - A Flask route module — 278 lines
- A frontend JavaScript file — Monaco, terminal, chat — 3,658 lines
- Two CSS files and two HTML templates for the embedded IDE panel
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.
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
| Endpoint | Method | Purpose |
|---|---|---|
/livecode-agent | POST | Main turn — returns text/event-stream |
/livecode/sessions | GET | List saved sessions for a project |
/livecode/session | GET | Load one session's canonical history |
/livecode/session/fork | POST | Fork a session at its current state |
/livecode/session/rewind | POST | Roll history back to message N |
/livecode/session/rename | POST | Rename a session |
/livecode/session/delete | POST | Delete a session |
/livecode/permission | POST | Approve or deny a sensitive tool call |
/livecode/interject | POST | Inject a mid-turn user message |
/livecode/index | POST | Force-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.
One turn: classification, iteration loop, tool dispatch, and dual-channel streaming back to the browser
Harness loop with stationarity gate, in-turn compaction, tool dispatch, and graceful exhaustion exit
Sensitive tools block the worker thread until the user approves via REST
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:
| File | Lines | Responsibility |
|---|---|---|
harness.py | 1,072 | Agentic tool-calling loop — messages, classification, LLM, tools, SSE, compaction, stationarity |
tools.py | 864 | 14 tool schemas, dispatch_tool(), implementations, result compaction, UI labels |
prompts.py | 127 | System/compaction/classifier prompts and tunable constants |
session.py | 509 | JSONL persistence, compaction sidecar, API sanitization, session CRUD |
codebase_index.py | 302 | Python AST + JS/TS regex symbol extraction, disk cache, watchdog |
workspace.py | 316 | File manifest, gitignore filtering, safe path resolution, layout tree |
context.py | 92 | Facade over compaction subpackage |
compaction/intra.py | 212 | In-turn dedupe and shrink ladder for stale tool messages |
compaction/full_replace.py | 179 | LLM-driven full-conversation summarization |
compaction/inter.py | 60 | Lighter inter-turn compaction before hard threshold |
memory.py | 48 | Cross-session per-project markdown memory |
routing.py | 177 | Heuristic + LLM classification, tool-choice policy |
reminders.py | 76 | Session-scoped reminder state injected into system prompt |
interjection.py | 34 | Thread-safe queue for mid-turn user messages |
permissions.py | 73 | Blocking permission gate for sensitive tools |
subagent.py | 61 | Nested child harness runs for spawn_subagent |
rules.py | 145 | Discovers 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)
- Classify the incoming question — heuristics first, LLM fallback if inconclusive
- Build/refresh workspace file manifest (cheap no-op if mtime fingerprint matches cache)
- Build/refresh symbol index (Python AST + JS/TS regex, disk-cached)
- Possibly compact session if history is already near token budget
- 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
| Symbol | Meaning |
|---|---|
MAX_ITERATIONS | Hard cap (20) on LLM calls per user message |
iteration | 1-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:
| Tool | Core behavior |
|---|---|
find_symbol | Substring match on in-memory symbol table, optional kind filter |
find_references | Compiles \bname\b regex, reads indexed files line-by-line |
list_symbols | Filters symbol table by file/directory prefix |
grep_repo | ripgrep subprocess, results capped at 16,000 chars |
read_repo_file | Max 300 numbered lines per call |
list_repo_dir | Root path → full project tree; else directory listing |
write_file | Read old → write new → generate diff |
edit_file | SEARCH/REPLACE with ambiguity rejection |
run_command | Shell subprocess, blocklist, streamed output over WebSocket |
git_log | Native git log parsed to structured dicts |
ast_symbols | Python AST symbol extractor for one file |
update_memory | Appends markdown bullet to per-project memory file |
spawn_subagent | Nested 5-iteration child harness run |
attempt_completion | Sentinel 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
- Purpose: flat list of every file in the project
- Used by: grep_repo, read_repo_file, list_repo_dir, project layout tree
- Storage:
~/.livecode/index/<project_hash>.json - Cache invalidation: max mtime of project root + top-level entries
- Limits: 8,000 files, 512 KB per file
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:
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:
- Session management — list, rename, delete, fork via REST endpoints
- Monaco editor — vendored AMD bundle, dynamic language from file extension, theme sync
- Terminal — xterm.js multi-tab terminals, Socket.IO
terminal_outputchannel - File explorer — Finder-style breadcrumb, open tabs, auto-save
- Multi-tab chat — per-tab state in
sessionStorage - Streaming — manual ReadableStream SSE parsing, incremental markdown render
- Activity chips — Cursor-style collapsible tool rows from WebSocket events
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
- 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.
- 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%.
- Hash-verified compaction — stale summaries discarded after rewind or mutation.
- Ambiguity-rejecting file edits — wrong-occurrence edits are worse than asking the model to retry with more context.
- Hard iteration ceiling with graceful exit — 20 iterations max, but exhaustion always triggers a final summarization pass.
- Recency-biased memory — flat markdown, last N characters, no embeddings.
- No orchestration framework — generator + dispatch table + JSONL is debuggable from browser event to exact Python line.
Summary
| Component | Implementation | Key detail |
|---|---|---|
| Agent loop | Python generator | 20-iteration cap, stationarity detection, codebase-evidence recovery |
| Tool calling | OpenAI function calling, 14 tools | Parallel dispatch, permission gate for sensitive tools |
| File editing | SEARCH/REPLACE | Rejects ambiguous matches (>1 occurrence) |
| Context compaction | Three-tier | LLM summarization only at 85% threshold |
| Session persistence | JSONL + sidecar JSON | Hash-verified compaction validity |
| Symbol indexing | Python AST + JS/TS regex | Per-project singleton, optional watchdog |
| Memory | Flat markdown per project | Last 2,048 chars read, no embeddings |
| Real-time | SSE + WebSocket | Two channels, clean separation |
| Diff generation | difflib.SequenceMatcher | autojunk=False, 2-line context, small-diff auto-expand |
| Permissions | threading.Event gate | Frontend unblocks worker via POST |
| Sub-agents | Nested harness, max 5 iterations | Ephemeral child session, single result to parent |
| Project rules | AGENTS.md / CLAUDE.md walk | Follow-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.
Related guides
- How SSE Streaming Works in Chatbots
- Migrate Cursor Chat to Claude Code
- How to Build a Production RAG Chatbot
Generator loop. Tool dispatch. JSONL on disk. That is the whole trick.