diff --git a/compiler/mod.ts b/compiler/mod.ts
new file mode 100644
index 0000000..be72e5a
--- /dev/null
+++ b/compiler/mod.ts
@@ -0,0 +1,14 @@
+import { Stmt } from "./ast.ts";
+import { Lexer } from "./Lexer.ts";
+import { Parser } from "./Parser.ts";
+
+export * from "./Parser.ts";
+export * from "./ast.ts";
+export * from "./arch.ts";
+export * from "./Lexer.ts";
+export * from "./Token.ts";
+
+export async function compileWithDebug(filepath: string): Promise<Stmt[]> {
+    const text = await Deno.readTextFile(filepath);
+    return new Parser(new Lexer(text)).parseStmts();
+}
diff --git a/web/deno.lock b/web/deno.lock
index cdf7a03..36c9b3b 100644
--- a/web/deno.lock
+++ b/web/deno.lock
@@ -6,6 +6,7 @@
     "jsr:@std/assert@1": "1.0.8",
     "jsr:@std/bytes@1": "1.0.4",
     "jsr:@std/bytes@^1.0.2": "1.0.4",
+    "jsr:@std/cli@*": "1.0.6",
     "jsr:@std/crypto@1": "1.0.3",
     "jsr:@std/encoding@1": "1.0.5",
     "jsr:@std/encoding@^1.0.5": "1.0.5",
@@ -48,6 +49,9 @@
     "@std/bytes@1.0.4": {
       "integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc"
     },
+    "@std/cli@1.0.6": {
+      "integrity": "d22d8b38c66c666d7ad1f2a66c5b122da1704f985d3c47f01129f05abb6c5d3d"
+    },
     "@std/crypto@1.0.3": {
       "integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
     },
diff --git a/web/main.ts b/web/main.ts
index e98f8cc..6313814 100644
--- a/web/main.ts
+++ b/web/main.ts
@@ -1,13 +1,110 @@
 import { Application, Router } from "jsr:@oak/oak";
+import { parseArgs } from "jsr:@std/cli/parse-args";
+import { Runtime } from "./runtime.ts";
+import * as compiler from "../compiler/mod.ts";
 
+const port = 8000;
 
-const app = new Application();
+const flags = parseArgs(Deno.args, {
+    boolean: ["flame-graph", "code-coverage"],
+});
+
+if (flags._.length !== 1) {
+    throw new Error("please specify a filename");
+}
+
+const filepath = flags._[0] as string;
+const text = await Deno.readTextFile(filepath);
+
+const runtime = new Runtime(13370);
+
+async function compileProgram(filepath: string) {
+    const result = await compiler.compileWithDebug(filepath);
+}
+
+async function runProgramWithDebug(program: string) {
+    const connection = await runtime.connect();
+    connection.send({
+        type: "run-debug",
+        program,
+    });
+    const res = await connection.receive<{
+        ok: boolean;
+    }>();
+    connection.close();
+    if (!res.ok) {
+        throw new Error("could not run code");
+    }
+}
 
 const router = new Router();
 
+router.get("/api/source", (ctx) => {
+    ctx.response.body = { ok: true, filepath, text };
+    ctx.response.status = 200;
+    ctx.respond = true;
+});
+
+router.get("/api/status", async (ctx) => {
+    const connection = await runtime.connect();
+    connection.send({ type: "status" });
+    const res = await connection.receive<{
+        ok: boolean;
+        status: "running" | "done";
+    }>();
+    connection.close();
+    if (!res.ok) {
+        ctx.response.body = { ok: false };
+        ctx.response.status = 500;
+        ctx.respond = true;
+        return;
+    }
+    ctx.response.body = { ok: true, status: res.status };
+    ctx.response.status = 200;
+    ctx.respond = true;
+});
+
+router.get("/api/flame-graph", async (ctx) => {
+    const connection = await runtime.connect();
+    connection.send({ type: "flame-graph" });
+    const res = await connection.receive<{
+        ok: boolean;
+        flameGraph: string;
+    }>();
+    connection.close();
+    if (!res.ok) {
+        ctx.response.body = { ok: false };
+        ctx.response.status = 500;
+        ctx.respond = true;
+        return;
+    }
+    ctx.response.body = { ok: true, flameGraph: res.flameGraph };
+    ctx.response.status = 200;
+    ctx.respond = true;
+});
+
+router.get("/api/code-coverage", async (ctx) => {
+    const connection = await runtime.connect();
+    connection.send({ type: "code-coverage" });
+    const res = await connection.receive<{
+        ok: boolean;
+        codeCoverage: string;
+    }>();
+    connection.close();
+    if (!res.ok) {
+        ctx.response.body = { ok: false };
+        ctx.response.status = 500;
+        ctx.respond = true;
+        return;
+    }
+    ctx.response.body = { ok: true, codeCoverage: res.codeCoverage };
+    ctx.response.status = 200;
+    ctx.respond = true;
+});
+
+const app = new Application();
 app.use(router.routes());
 app.use(router.allowedMethods());
-
 app.use(async (ctx, next) => {
     try {
         await ctx.send({ root: "./public", index: "index.html" });
@@ -15,8 +112,6 @@ app.use(async (ctx, next) => {
         next();
     }
 });
-
-const port = 8000;
 const listener = app.listen({ port });
-console.log(`Listening at http://localhost:${port}/`);
+console.log(`Devtools at http://localhost:${port}/`);
 await listener;
diff --git a/web/public/index.js b/web/public/index.js
index 2431495..f5d18b0 100644
--- a/web/public/index.js
+++ b/web/public/index.js
@@ -30,6 +30,11 @@ function loadCodeCoverage(text, codeCoverageData) {
     let line = 1;
     let col = 1;
     for (let index = 0; index < text.length; ++index) {
+        if (text[index] == "\n") {
+            col = 1;
+            line += 1;
+            continue;
+        }
         const entry = entries.find((entry) => index >= entry.index);
         charEntries[`${line}-${col}`] = entry;
         ctx.fillStyle = color(Math.min(entry.covers / 25, 1));
@@ -40,10 +45,6 @@ function loadCodeCoverage(text, codeCoverageData) {
             chHeight,
         );
         col += 1;
-        if (text[index] == "\n") {
-            col = 1;
-            line += 1;
-        }
     }
 
     const tooltip = document.getElementById("covers-tooltip");
diff --git a/web/public/style.css b/web/public/style.css
index 27d694d..c1a4d71 100644
--- a/web/public/style.css
+++ b/web/public/style.css
@@ -50,7 +50,7 @@ main {
 }
 #code-coverage #covers-tooltip {
     z-index: 2;
-    position: absolute;
+    position: fixed;
     top: 0;
     left: 0;
     padding: 3px;
@@ -78,7 +78,7 @@ main {
 }
 #flame-graph #flame-graph-tooltip {
     z-index: 2;
-    position: absolute;
+    position: fixed;
     top: 0;
     left: 0;
     padding: 3px;
diff --git a/web/runtime.ts b/web/runtime.ts
new file mode 100644
index 0000000..97fbbce
--- /dev/null
+++ b/web/runtime.ts
@@ -0,0 +1,45 @@
+export class Runtime {
+    constructor(private port: number) {}
+
+    async connect(): Promise<RuntimeConnection> {
+        return new RuntimeConnection(
+            await Deno.connect({
+                port: this.port,
+            }),
+        );
+    }
+}
+
+export class RuntimeConnection {
+    constructor(private connection: Deno.Conn) {}
+
+    async write(text: string): Promise<void> {
+        const req = new TextEncoder().encode(text);
+        await this.connection.write(req);
+    }
+
+    async send<T>(value: T): Promise<void> {
+        await this.write(JSON.stringify(value));
+    }
+
+    async read(): Promise<string> {
+        let result = "";
+        while (true) {
+            const buf = new Uint8Array(256);
+            const readRes = await this.connection.read(buf);
+            result += new TextDecoder().decode(buf);
+            if (readRes == null) {
+                break;
+            }
+        }
+        return result;
+    }
+
+    async receive<T>(): Promise<T> {
+        return JSON.parse(await this.read()) as T;
+    }
+
+    close() {
+        this.connection.close();
+    }
+}