v1.0.0 — Stable Release · Apache 2.0 · Open Source

A scripting language
built for the web.

Routing built into the language. Persistent DB connections. WebSocket, SSE, parallel(), jobs::, template:: — all in a single binary. No framework to set up — just write.

⇓ Download — Free Code examples →
index.lk — real-time dashboard
app::set("db", db::connect("mysql://root:@127.0.0.1/shop"))
# REST — 3 queries in parallel, capture-free DB access (app::db)
route("GET", "/api/dashboard", function() {
    $ch = channel(3)
    parallel(function() use ($ch) { send($ch, db::col(app::db(), "SELECT count(*) FROM orders", [])) })
    parallel(function() use ($ch) { send($ch, db::col(app::db(), "SELECT sum(total) FROM orders", [])) })
    parallel(function() use ($ch) { send($ch, db::col(app::db(), "SELECT count(*) FROM users", [])) })
    response::json(["orders" => receive($ch), "revenue" => receive($ch), "users" => receive($ch)])
})
# SSE — live notification stream
route("SSE", "/events", function($sse) {
    $id = timer::every(3000, function() use ($sse) {
        sse::send($sse, json::encode(["t" => date::now()]), "tick")
    })
    sse::on($sse, "close", function() use ($id) { timer::cancel($id) })
})
Warm Start Runtime
WebSocket + SSE
parallel() + channel()
SQLite · MySQL · PostgreSQL
jobs:: + queue:: + cache::
Bytecode VM · up to ~50× faster (CLI)
Apache / nginx / standalone
Everything a web app needs

LOOK is a scripting language designed for server-side web development. Routing, DB, concurrency, real-time connections, background jobs and a template engine — all inside a single binary.

Warm Start Runtime

The app starts once. DB connection, route registrations, setup code — it all stays in memory. Every HTTP request only runs dispatch_routes().

32 workers, 4.7 million requests, 10 minutes — 0 errors, 8 MB RAM.

🔀

Routing Built Into the Language

route("GET", "/product/{id}", fn) — this isn't a library call, it's the core of the language. No router to set up, no config, no middleware stack.

REST, WebSocket, SSE — all with the same route() syntax.

🚀

parallel() + channel()

Lightweight concurrency, in a web scripting language. Run 3 DB queries at once and collect the results with receive() — total time is max(t₁, t₂, t₃).

No extra runtime, no extra library, nothing new to learn for concurrency.

📦

Real-time: WS + SSE

route("WS", "/chat", fn) — WebSocket, fully RFC 6455 compliant. route("SSE", "/events", fn) — Server-Sent Events, together with timer::every().

500 concurrent WebSocket connections, 72 hours, 0 memory leaks.

When to choose LOOK

Not a framework — a language. If any of the needs below is yours, LOOK is a great fit.

🐘

Coming from PHP, wanting more

You love the ease of shared hosting / Plesk but want WebSocket, SSE and concurrency. Drop the file, it runs — plus real-time connections and parallel().

🚀

REST + DB without a framework

Routing, DB (MySQL/SQLite/PostgreSQL), validation, cache, JWT — all built into the language. No composer/npm dependency tree, one binary.

📦

Single binary deploy

Copy one file to a VPS, run it with systemd/Plesk. No separate runtime, no package manager, no surprises. 28 MB RAM, steady across 1M requests.

✉️

You want to run a mail system

Built-in SMTP + IMAP server — no Postfix/Dovecot needed. Build a secure mail system, e-commerce notifications, a blog with LOOK. How? ↓

Performance
Real numbers, real server

AlmaLinux 8 · MariaDB · measured on a direct port, 64 workers.

0
RPS
HTTP + VM, router
c=200, 0 errors
0
requests
Endurance run
c=10,000, 0 errors
0
concurrent
Concurrency ceiling
single-machine ab limit, 0 errors
41
× faster
Bytecode VM speedup
CLI vs. tree-walk interpreter (3652 ms → 72 ms)
28
MB
Memory — steady
not a single byte grew over 1.75M requests
0
errors
All load tests
c=1k / 5k / 10k / 20k + 1M

All tests were measured with ab on a live AlmaLinux 8 server (see test.codlook.com for applications running on it). Throughput is steady from c=1,000 to c=20,000 (~7.5–10k) — the bottleneck is CPU; kernel walls (backlog, syn_backlog) were cleared with tuning. The TLS chain (nginx→FCGI) does ~1,100–1,240 RPS; the difference comes from double TLS termination, not LOOK. DB-bound endpoints are limited by MySQL RTT.

Proven resilience

Every line tested. Passed fuzzing, ThreadSanitizer and data-integrity tests.

16,000+
Fuzzing iterations
Random/malformed HTTP bombardment under AddressSanitizer + UndefinedBehaviorSanitizer — 0 crashes, 0 UB, 0 memory errors.
0 races
ThreadSanitizer clean
Concurrent-dispatch data-race scan. The one race found (date::now tz) was fixed — now zero.
20,000
Cross-contamination test
Each request a unique token + DB round-trip. 0 data leaks in blocking and fiber mode — every request saw only its own data.
Zero
Supply-chain risk
MySQL, PostgreSQL, SQLite and Redis (RESP2) wire protocols — all written from scratch in C++23. TLS uses the OS crypto library (OpenSSL / Schannel), statically pinned in release builds. No third-party packages, no dependency hell.
4 rounds
Manual security audit
Beyond fuzzing: protocol parsers, VM arithmetic and network paths reviewed line by line. Every hardening point found (unguarded number conversion, unbounded allocation, signed-overflow UB) was closed and tested.

Vulnerabilities closed & protections

Topic LOOK protection
SQL injection db::query($c, sql, [params]) — auto-parameterised ?, driver-correct escaping
DoS — huge body LOOK_MAX_BODY_SIZE (10 MB) exceeded → 413; unbounded-body memory exhaustion prevented
Request smuggling Content-Length + Transfer-Encoding together → 400 (RFC 7230 §3.3.3)
Malformed header Malformed Content-Length → 400 (worker doesn't crash); full RFC 7230 chunked TE support
Slowloris / slow requests Read/write timeout (30s) + kernel backlog 16384 + syn_backlog tuning
Session security 128-bit /dev/urandom token; cookie HttpOnly + Secure + SameSite
Password storage auth::hash / verify — PBKDF2-SHA256, in the core
Upload security Magic-byte type validation (blocks extension spoofing) + SHA-256
WebSocket masking Unmasked client frame → connection closed with 1002 (RFC 6455 §5.1) — cache-poisoning / proxy defense, no silent accept
JSON stack overflow Deeply nested JSON body → recursion depth limit (JSON_MAX_DEPTH) — stack overflow prevented
Parser stack overflow Deeply nested ((…)) / [[…]] in source → expression/statement depth guards (150) — segfault prevented
Response splitting CR/LF in a response header / cookie / redirect → stripped at the setter and at serialization — no header injection
Protocol parser DoS RESP2/HTTP/PG/SMTP: unguarded number conversion + unbounded allocation closed (a malformed server response can't crash a worker)
Arithmetic UB 64-bit integers; signed-overflow, shift and unary-minus undefined behaviour eliminated (promote to float only past ±9.2×10¹⁸) — UBSan clean. BIGINT / IDs / phone numbers round-trip exactly

Before every build, 9 regression tests + automated ASan/UBSan fuzzing and ThreadSanitizer run in CI (.github/workflows/security.yml). Memory: RSS steady over 1.75M requests — no leak.

Embedded Mail Server — SMTP + IMAP

Most scripting languages send mail; LOOK serves it. A built-in SMTP (RFC 5321) and IMAP4rev1 (RFC 3501 + IDLE) server — no Postfix/Dovecot needed. You can build a fully secure mail system end to end with LOOK.

# SMTP receives → Maildir → IMAP serves (one command)
SMTP :25/:587 ─deliver─▶ <LOOK_MAIL_DIR>/<user>/inbox ◀─reads─ IMAP :143/:993

lk --mode http --port 7400 # HTTP + SMTP + IMAP + IMAPS together
STARTTLS + IMAPS (993) SEARCH · FETCH · APPEND IDLE — live push mail_users PBKDF2 auth ASan+UBSan fuzz: 0 UB

Thunderbird, Roundcube, Apple Mail and mobile clients connect directly — list, read, write, search, receive live notifications. All over TLS.

One binary, a full runtime

Write directly — nothing to install, nothing new to learn.

Core
🔀

Routing built into the language

route("GET", "/product/{id}", fn) — REST, WS, SSE, same syntax. URL params parsed automatically.

Syntax

Short lambda — fn + arrow

fn($x) => $x * 2 — a function alias + expression-bodied arrow with implicit return. route("GET","/ping", fn() => response::json(["ok"=>true])). Classic function works too.

Syntax
🧩

app:: service registry

No more passing a shared connection to every route with use ($conn). app::set("db", $conn) once at setup → routes use app::db() with no capture. The explicit-capture philosophy is preserved.

Core
🗄️

MySQL · SQLite · PostgreSQL

Wire protocol implemented directly. Same API: db::query / db::exec / db::col. Transactions: db::begin / commit / rollback.

Concurrency
⚙️

parallel() + channel()

The LOOK task model. Fan-out, pipeline, producer-consumer — over channels. No blocking, no callbacks. Monitor with parallel::active() / limit() / at_capacity().

Real-time
🔌

WebSocket

route("WS", "/chat", fn) — RFC 6455, SHA-1+Base64 handshake. ws::broadcast() to all connected clients.

Real-time
📡

SSE + timer::

route("SSE", "/events", fn) — Server-Sent Events. Periodic push with timer::every(3000, fn).

Background
📬

jobs:: — job queue

SQLite-backed durable queue. jobs::push / worker / run. Delayed jobs, retry, dead-letter. Not lost when the process exits.

Background
📥

queue:: — FIFO queue

In-memory, thread-safe, named queue. Passing data between requests. queue::push / pop / peek / size.

Cache
🧠

cache:: — in-memory cache

TTL-aware, thread-safe singleton. Shared across all workers with warm start. cache::get / set / has / flush.

Template
🎨

template:: — template engine

Layout inheritance, partial includes, {#each} loops, {#if} conditions. Svelte-like — no runtime, server render only.

VM
🚀

Bytecode VM

AST → register-based bytecode → switch dispatch. The default engine everywhere: ~41–51× faster than the tree-walk interpreter on the CLI, and up to 7.8× on compute-heavy web workloads.

Module
📧

mail:: — sending email

Mailgun, SendGrid, Postmark supported. mail::send / send_html. Choose the provider with the MAIL_PROVIDER env var.

Security
🔐

crypto:: + JWT

SHA-256, HMAC-SHA256, RS256, UUID v4, timing-safe compare. Sign and verify HS256/RS256 tokens with the official pkg/jwt package.

Security
🛡️

Rate Limiter — Token Bucket

Two-layer protection: per-IP burst tolerance + global botnet cap. LOOK_RATE_LIMIT_RPM, LOOK_RATE_LIMIT_BURST, LOOK_RATE_LIMIT_GLOBAL_RPM. Your LOOK code doesn't change — it's at the runtime level.

Security
✉️

DKIM + SMTP Protection

Outbound e-mail is automatically signed with RSA-SHA256 (LOOK_SMTP_DKIM_*). Port 25 relay is blocked — authenticated SMTP only (587/465).

Mail Server
📥

Embedded SMTP + IMAP

Embedded MTA (25/587) + IMAP4rev1 server (143/993). SMTP delivers to Maildir, IMAP serves it to Thunderbird/Roundcube. STARTTLS, SEARCH, live IDLE push. Same mail_users pbkdf2 identity. No Dovecot/Postfix required.

Error Handling
⚠️

error:: — Type-Safe Errors

Raise your own errors with throw — a plain string or a structured error::new($type, $message [, $code]). Catch with try/catch; inspect via error::is($e, $type), error::code($e), error::message($e). No use required — always on.

Module
🌐

http:: — Outbound HTTP Client

GET, POST, PUT, PATCH, DELETE. JSON body, custom headers, timeout. TLS included. Fan-out parallel API calls with parallel().

Ecosystem
📦

Package + Module System

lk install github.com/codlook/look-packages/... — JWT, payments and more. Explore the Codlook-approved ecosystem at packages.codlook.com. No registry, no config.

Developer
🧪

Test + REPL + look install

look test — 10 assertions, before_each/after_each. look repl — interactive. Install packages from GitHub with look install github.com/user/repo.

Meet the real code
index.lk — REST API, auth, validatorBasics
$conn = db::connect("mysql://root:@127.0.0.1/blog")
use auth
use validator
use cache
function admin_check() {
    session::start()
    if (session::get("admin_id") == null) {
        response::error(401, "Unauthorized")
        return false
    }
    return true
}

route("GET", "/api/posts", function() use ($conn) {
    # 5-minute cache — don't hit the DB for the same data over and over
    $data = cache::get("posts")
    if ($data == null) {
        $data = db::query($conn, "SELECT * FROM posts ORDER BY id DESC", [])
        cache::set("posts", $data, 300)
    }
    response::json(["ok" => true, "data" => $data])
})
route("POST", "/api/posts", function() use ($conn) {
    if (!admin_check()) { return }
    $v = validator::check(request::json(), [
        "title"  => ["required", "min:3", "max:200"],
        "content"  => ["required", "min:10"]
    ])
    if (!$v["ok"]) { response::json($v, 422) return }
    $data = request::json()
    db::exec($conn, "INSERT INTO posts (title,content) VALUES (?,?)", [$data["title"], $data["content"]])
    cache::delete("posts")  # cache invalidate
    response::json(["ok" => true, "id" => db::last_id($conn)])
})
chat.lk — Real-time chat, broadcast hubWebSocket
# Hub: the shared broadcast channel for all connections (setup phase)
$hub = channel()
route("WS", "/chat", function($ws) use ($hub) {

    # One thread per connection: listen on the hub → write to this client
    parallel(function() use ($ws, $hub) {
        while (true) {
            $msg = receive($hub)
            if ($msg == null) { break }
            ws::send($ws, $msg)
        }
    })
    # On incoming message, push to the hub → every client hears it
    ws::on($ws, "message", function($data) use ($hub) {
        $p = json::decode($data)
        send($hub, json::encode(["from" => request::ip(), "msg" => $p["msg"]]))
    })
    ws::on($ws, "close", function() {
        log::info("Connection closed — active: " . ws::clients())
    })
})
# Keepalive ping — to all clients every 30 seconds
timer::every(30000, function() {
    ws::broadcast(json::encode(["type" => "ping"]))
})
parallel.lk — 3 queries at once, total time = max(t1,t2,t3)parallel()
$conn = db::connect("mysql://root:@127.0.0.1/shop")
route("GET", "/panel/summary", function() use ($conn) {

    $ch = channel(3)  # waiting for 3 results

    parallel(function() use ($ch, $conn) {
        send($ch, db::col($conn, "SELECT count(*) FROM orders", []))
    })
    parallel(function() use ($ch, $conn) {
        send($ch, db::col($conn, "SELECT sum(total) FROM orders WHERE status='paid'", []))
    })
    parallel(function() use ($ch, $conn) {
        send($ch, db::col($conn, "SELECT count(*) FROM users WHERE active=1", []))
    })
    # Won't continue until all parallel jobs finish
    $orders  = receive($ch)
    $revenue       = receive($ch)
    $users = receive($ch)
    response::json([
        "orders"   => $orders,
        "revenue"        => $revenue,
        "users" => $users
    ])
})
sse.lk — Live notification stream, periodic pushSSE + timer::
$conn = db::connect("mysql://root:@127.0.0.1/shop")
# SSE: once the browser connects, a continuous data stream is pushed
route("SSE", "/live/orders", function($sse) use ($conn) {

    # Check for new orders every 2 seconds
    $tick = timer::every(2000, function() use ($sse, $conn) {
        $new = db::query($conn,
            "SELECT * FROM orders WHERE created_at > date_sub(now(), interval 5 second)", [])
        if (count($new) > 0) {
            $ok = sse::send($sse, json::encode($new), "new_order")
            if (!$ok) { timer::cancel($tick) }   # connection closed
        }
    })
    # 30s keepalive — so the browser doesn't time out
    $ping = timer::every(30000, function() use ($sse) {
        sse::send($sse, "keepalive", "ping")
    })
    sse::on($sse, "close", function() use ($tick, $ping) {
        timer::cancel($tick)
        timer::cancel($ping)
        log::info("SSE closed — active: " . sse::clients())
    })
})
jobs.lk — SQLite durable queue, retry, mail deliveryjobs::
use jobs
use mail
# Route: respond instantly (1ms), enqueue the work
route("POST", "/signup", function() use ($conn) {
    $data = request::json()
    db::exec($conn, "INSERT INTO users (email,name) VALUES (?,?)",
             [$data["email"], $data["name"]])
    # max 3 attempts, process now (delay_sec=0)
    jobs::push("welcome_email", ["to" => $data["email"], "name" => $data["name"]], 3, 0)
    # reminder email after 10 minutes
    jobs::push("reminder_email", ["to" => $data["email"]], 3, 600)
    response::json(["ok" => true])
})
# worker.lk — separate process (runs as a systemd service)
jobs::recover("welcome_email")  # crash recovery

jobs::worker("welcome_email", function($job) {
    $r = mail::send($job["payload"]["to"], "Welcome!",
                    "Hi " . $job["payload"]["name"] . ", your account is ready.")
    return $r["ok"]  # false → retry (max 3), then dead-letter
})
jobs::run(5000)  # poll every 5 seconds
blog.lk — Struct + template engine (server-side render)template::
use template
# Struct — Go style, no methods, data only
struct Post {
    id
    title
    content
    date
    likes: 0
    published: true
}

$tpl = env("VIEWS_DIR", "/app/views")
route("GET", "/blog", function() use ($conn, $tpl) {
    $rows = db::query($conn, "SELECT * FROM posts WHERE published=1", [])
    print(template::render($tpl . "/blog/index", [
        "title"  => "Blog",
        "posts" => $rows
    ]))
})

# views/blog/index.html — server-side, no JS
# {#extends "views/layout/base"}
# {#block "content"}
# {#each $posts as $p}
#   <article>
#     <h2>{$p.title}</h2>
#     <p>{$p.date} · {$p.likes} likes</p>
#     <div>{!$p.content}</div>
#   </article>
# {/each}
# {#empty}<p>No posts yet.</p>{/each}
# {/block}
api.lk — protect the API with JWT, install packages via look installpkg/jwt
# Terminal: look install github.com/Codlook/look-packages
use "pkg/jwt/jwt.lk"
use auth
use crypto
$conn = db::connect(env("DB_DSN", "mysql://root:@127.0.0.1/app"))
# Login — issue a token
route("POST", "/api/login", function() use ($conn) {
    $email = request::post("email") ?? ""
    $password = request::post("password") ?? ""
    $rows = db::query($conn, "SELECT * FROM users WHERE email=?", [$email])
    if (count($rows) == 0 || !auth::verify($password, $rows[0]["password_hash"])) {
        response::error(401, "Invalid credentials")
        return
    }

    $token = jwt_sign(
        ["user_id" => $rows[0]["id"], "role" => $rows[0]["role"]],
        env("JWT_SECRET", ""),
        ["exp" => 86400]   # 24 hours
    )
    response::json(["ok" => true, "token" => $token])
})
# Protected endpoint — Bearer token required
function auth_required() {
    $header = request::header("Authorization") ?? ""
    $token  = string::substr($header, 7)  # strip "Bearer "
    $p = jwt_verify($token, env("JWT_SECRET", ""))
    if ($p == null) {
        response::json(["error" => "Invalid token"], 401)
        return null
    }
    return $p
}

route("GET", "/api/profile", function() use ($conn) {
    $user = auth_required()
    if ($user == null) { return }

    $rows = db::query($conn, "SELECT id,name,email FROM users WHERE id=?", [$user["user_id"]])
    response::json(["ok" => true, "data" => $rows[0]])
})
# Webhook verification — HMAC-SHA256 timing-safe
route("POST", "/webhook/payment", function() {
    $payload  = request::body()
    $given  = request::header("X-Signature") ?? ""
    $expected = "sha256=" . crypto::hmac_sha256($payload, env("WEBHOOK_SECRET", ""))
    if (!crypto::constant_compare($given, $expected)) {
        response::status(401) return
    }
    # Signature valid — process the payment...
    response::json(["ok" => true])
})
Try it in the browser COMING SOON

Write and run LOOK code in the browser — no install. It's in the works — once ready, a live editor will sit right here.

playground.lk — runnable soon
route("GET", "/", fn() => response::json(["ok" => true, "message" => "Hello LOOK!"]))

# ▶ The Run button will appear here once it's ready

For now: download and run it in seconds with lk --mode http.

A single LOOK file

Login and data fetching — one file, no framework, no surprises.

LOOK index.lk — login + data fetching
use auth;

$conn = db::connect("mysql://root:@localhost/blog")
# Opened once — stays alive across every request

route("POST", "/login", function() use ($conn) {
    session::start()
    $d = request::json()

    $rows = db::query($conn,
        "SELECT * FROM users WHERE email=?", [$d["email"]])
    # ? parameterised — no SQL injection, no XSS sanitising

    if (count($rows) == 0 ||
        !auth::verify($d["password"], $rows[0]["password_hash"])) {
        response::json(["ok" => false], 401)
        return
    }
    session::set("user_id", $rows[0]["id"])
    response::redirect("/panel")
})

route("GET", "/posts", function() use ($conn) {
    $rows = db::query($conn, "SELECT * FROM posts", [])
    response::json($rows)
})
request:: — zero globals Prepared stmt automatic DB connection persistent auth:: PBKDF2 built in
Topic How LOOK does it
DB connection Warm start — opened once, stays alive across every request
Request data No globals — request:: / session:: namespaces
Routing Built into the language — route("GET", "/path/{id}", fn)
SQL injection protection db::query auto-parameterised (? placeholder)
Concurrency parallel() + channel() inside the language
WebSocket / SSE route("WS", ...) — part of the core
Performance (router) Bytecode VM — 10,000+ RPS on a direct port, single core
Three rules, never broken

Every new feature must serve these three principles — otherwise it doesn't make it into the language.

🗺️
Drop it, run it
Drop the file on the server, let the server route it — done. Nothing to install, nothing to compile. Shared hosting, VPS, Plesk — the same workflow.
# GitHub Releases → download the binary cp look-fcgi /opt/look/ && chmod +x /opt/look/look-fcgi # → Apache .htaccess, systemd service
Explicit scope
Clean scope, explicit dependencies, concurrency built into the language. use() declares exactly what comes in. No hidden globals, no surprises.
route("GET", "/product/{id}", function($id) use ($conn) { response::json($row) })
🔒
No framework to set up
Routing, DB, auth, cache, jobs, WebSocket, SSE, templates — all inside the language. No package manager to open, no dependency hell.
use jobs # SQLite job queue use cache # in-memory TTL cache use mail # Mailgun/SendGrid # No install. It's all inside the binary.
How deep does it go?

Every component of LOOK is written in C++23.

Parser

Lexer + Parser + AST

Precedence-aware parser, full operator set, string interpolation, error positions via SourceLocation.

VM

Bytecode VM

AST → 3-address register bytecode → switch dispatch. 7.8× faster on compute workloads. Fallback: tree-walk interpreter.

Concurrency

ThreadPool + ConnPool

--workers N, per-request interpreter copy, per-DSN connection pool. Hot reload: zero downtime.

Network

HTTP/1.1 + WebSocket + SSE

epoll (Linux), IOCP (Windows). RFC 6455 WS, SSE frame codec. Unlimited concurrent connections.

DB

MySQL · SQLite · PostgreSQL

Wire protocol from scratch. No driver, no ORM. Same db:: API, different DSN.

Deployment

CGI + FastCGI + HTTP

Apache/nginx FastCGI, standalone HTTP, CGI fallback. One binary, three modes.

Running in 5 minutes

Choose your platform:

One portable binary for glibc ≥ 2.28: AlmaLinux/Rocky/RHEL 8·9·10, Ubuntu 18.10→24.04, Debian 10+ — and future releases work automatically (built on old glibc + static OpenSSL). CentOS 7 / RHEL 7 (glibc 2.17, EOL) is not supported — migrate to AlmaLinux 8.

  1. Download and extract the package

    One package for Ubuntu/Debian + AlmaLinux/Rocky/RHEL — the same portable binary. From GitHub Releases:

    unzip look-lang-linux-1.0.0.zip -d look && cd look
  2. Install (one command)

    Installs the binary to /usr/local/bin, creates a sample app + systemd service (port 9000), starts it and verifies.

    sudo bash install.sh
  3. Verify

    curl http://127.0.0.1:9000/ · systemctl status look

    Prefer to manage it with dnf on AlmaLinux/RHEL: dnf install look-lang-1.0.0-1.el8.x86_64.rpm (then dnf update look-lang).

  1. Install XAMPP

    If XAMPP is already installed, skip this step.

  2. Extract and install the package (Administrator PowerShell)

    Extract look-lang-xampp-1.0.0.zip; it copies lk-cgi.exe to cgi-bin, patches httpd.conf for .lk, and creates a sample test.lk.

    Expand-Archive look-lang-xampp-1.0.0.zip look; cd look; .\install.ps1

    Custom path: .\install.ps1 -XamppDir "D:\xampp" · Uninstall: .\uninstall.ps1

  3. Restart XAMPP Apache

    XAMPP Control Panel → Apache → Restart. Open http://localhost/test.lk — it's running.

  1. Open the Plesk Terminal

    Plesk Panel → Server Management → Terminal

    If you prefer SSH: ssh root@server_ip

  2. Download and install the ZIP

    plesk bin extension --uninstall look-lang 2>/dev/null; wget -O /tmp/look-lang-plesk-1.0.0.zip "https://github.com/codlook/look/releases/download/v1.0/look-lang-plesk-1.0.0.zip" && plesk bin extension --install /tmp/look-lang-plesk-1.0.0.zip

    You'll see "The extension was successfully installed."

  3. Finish setup — one root command (required on new servers)

    plesk php /usr/local/psa/admin/plib/modules/look-lang/scripts/post-install.php

    Plesk does not run the extension's install hook, so this installs the LOOK binary to /opt/look and grants sudo access. Without it, enabling a domain fails with sudo: a password is required. Safe to re-run; survives uninstall/install.

  4. Open the LOOK Language panel

    Plesk Panel → ExtensionsMy ExtensionsLOOK Language → Open

  5. Add a domain

    Applications → Add New Domain → pick a domain (script path and port fill in automatically) → Add & Start

    After a few seconds it shows Running in the list. The systemd service + Apache proxy are created automatically.

  6. Write your code

    Use Edit Code to edit index.lk in the browser → Save & Redeploy — the file is written and the service restarts.

Full workspace: live system dashboard, in-browser code editor, per-domain monitor (CPU/RAM/PID/connections), journalctl log viewer.
On a fresh server, one root command is needed to grant sudo access (see docs).

  1. Pull & run — nothing to build

    The official image is published on Docker Hub. One command downloads and starts LOOK — no source, no compiler. Your app lives in ./app, mounted at /app.

    docker run -p 7400:7400 -v "${PWD}/app:/app" codlook/look

    Put your index.lk in ./app, then open http://localhost:7400/. First run pulls the image (~43 MB) automatically.

  2. Or build the image yourself

    Prefer building from source? The multi-stage production image (AlmaLinux 8 base) compiles and packages LOOK:

    docker build -f cpp/docker/Dockerfile.production -t look:latest .
  3. Or just build the portable binary

    Build once on AlmaLinux 8 (glibc 2.28 + static OpenSSL) — the same single binary runs on every distribution (Ubuntu/Debian/AlmaLinux/Rocky/RHEL 8·9·10 and newer). No per-distro builds:

    docker build -t look-build -f cpp/Dockerfile.build cpp/ && docker run --rm -v "${PWD}/cpp:/look/cpp" -w /look/cpp look-build bash build-portable.sh

    Output: cpp/build-portable/{lk, lk-fcgi, lk-cgi} — statically linked (glibc + libz only). Verified on Ubuntu 22.04 and AlmaLinux 9 (OpenSSL 3.x).

Download
Built for web developers

v1.0.0 · Apache 2.0 · Stable release for every platform.

🐧 Linux — Ubuntu + AlmaLinux

One self-contained package. sudo bash install.sh → binary + systemd service. The same portable binary runs on Ubuntu/Debian and AlmaLinux/Rocky/RHEL.

⇓ look-lang-linux-1.0.0.zip → Install Guide

📦 AlmaLinux / RHEL — RPM

dnf-managed install. dnf install look-lang-…rpm → then update with dnf update look-lang. The binary links to the system OpenSSL (OS security patches).

⇓ look-lang-1.0.0-1.el8.x86_64.rpm → Plesk Alternative

🪟 Windows — XAMPP

One command for XAMPP. .\install.ps1 → lk-cgi.exe + httpd.conf patch. Unlimited connections via IOCP.

⇓ look-lang-xampp-1.0.0.zip → XAMPP Install Guide

🖥️ Plesk Extension

A full hosting panel for Plesk 18+ — code editor, live monitor, log viewer. Add a domain, write your code, go live.

⇓ look-lang-plesk-1.0.0.zip → GitHub →

🧩 VS Code Extension

IntelliSense (all built-ins + your own code), live error checking (syntax + undefined functions), outline, 20+ snippets, Run/Serve/REPL shortcuts. v2.0

⇓ Install from Marketplace → ⇓ look-lang-2.0.0.vsix (GitHub) →

📦 Source Code

C++23, CMake 3.20+. Apache 2.0 license. For contributing or building it yourself.

GitHub → codlook/look All Releases