diff --git a/Cargo.lock b/Cargo.lock
index 22f235e..ba8c491 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -365,6 +365,22 @@ dependencies = [
  "thiserror",
 ]
 
+[[package]]
+name = "launcher_nox"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clappconfig",
+ "crsn",
+ "crsn_arith",
+ "crsn_buf",
+ "crsn_stdio",
+ "log",
+ "serde",
+ "simple_logger",
+ "thiserror",
+]
+
 [[package]]
 name = "lazy_static"
 version = "1.4.0"
diff --git a/Cargo.toml b/Cargo.toml
index 7691d0d..31d54f3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,7 @@
 [workspace]
 members = [
     "launcher",
+    "launcher_nox",
     "crsn",
     "crsn_arith",
     "crsn_screen",
diff --git a/crsn_stdio/src/lib.rs b/crsn_stdio/src/lib.rs
index a054a92..c570ce6 100644
--- a/crsn_stdio/src/lib.rs
+++ b/crsn_stdio/src/lib.rs
@@ -9,6 +9,7 @@ use crsn::sexp::SourcePosition;
 use std::convert::TryFrom;
 use std::io;
 use crsn::asm::instr::cond::Flag;
+use std::fmt;
 
 mod console {
     use std::{io};
@@ -98,7 +99,7 @@ mod console {
     }
 }
 
-#[derive(Debug, Clone)]
+#[derive(Clone)]
 pub struct StdioOps {
     old_tio: Option<libc::termios>,
     hdl_stdin : Value,
@@ -107,6 +108,17 @@ pub struct StdioOps {
     hdl_stdout_raw : Value,
 }
 
+impl fmt::Debug for StdioOps {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("StdioOps")
+            .field("hdl_stdin", &format_args!("0x{:08x}", self.hdl_stdin))
+            .field("hdl_stdin_raw", &format_args!("0x{:08x}", self.hdl_stdin_raw))
+            .field("hdl_stdout", &format_args!("0x{:08x}", self.hdl_stdout))
+            .field("hdl_stdout_raw", &format_args!("0x{:08x}", self.hdl_stdout_raw))
+            .finish()
+    }
+}
+
 impl StdioOps {
     pub fn new() -> Box<dyn CrsnExtension> {
         Box::new(Self {
diff --git a/launcher_nox/Cargo.toml b/launcher_nox/Cargo.toml
new file mode 100644
index 0000000..c6d197d
--- /dev/null
+++ b/launcher_nox/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "launcher_nox"
+version = "0.1.0"
+authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
+edition = "2018"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+crsn = { path = "../crsn" }
+crsn_arith = { path = "../crsn_arith" }
+crsn_buf = { path = "../crsn_buf" }
+crsn_stdio = { path = "../crsn_stdio" }
+
+simple_logger = "1.9.0"
+log = "0.4.11"
+thiserror = "1.0.20"
+anyhow = "1.0.32"
+clappconfig = "0.4.0"
+
+serde = { version = "1.0.116", features = ["derive"] }
diff --git a/launcher_nox/src/main.rs b/launcher_nox/src/main.rs
new file mode 100644
index 0000000..d4d6a6c
--- /dev/null
+++ b/launcher_nox/src/main.rs
@@ -0,0 +1,172 @@
+#[macro_use]
+extern crate log;
+
+use std::collections::HashMap;
+
+use std::time::Duration;
+
+use clappconfig::{AppConfig, clap};
+use clappconfig::clap::ArgMatches;
+use serde::{Deserialize, Serialize};
+
+use crsn::asm::data::literal::Addr;
+use crsn::module::{OpTrait, CrsnUniq};
+use crsn::runtime::run_thread::{RunThread, ThreadToken, ThreadParams};
+use crsn_arith::ArithOps;
+use crsn_stdio::StdioOps;
+use crsn_buf::BufOps;
+
+mod read_file;
+mod serde_duration_millis;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct LogConfig {
+    level: String,
+    modules: HashMap<String, String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default)]
+struct Config {
+    log: LogConfig,
+    #[serde(skip)]
+    program_file: String,
+    #[serde(skip)]
+    assemble_only: bool,
+    #[serde(with = "serde_duration_millis")]
+    cycle_time: Duration,
+}
+
+impl Default for Config {
+    fn default() -> Self {
+        Self {
+            log: LogConfig {
+                level: "warn".to_string(),
+                modules: Default::default(),
+            },
+            program_file: "".to_string(),
+            assemble_only: false,
+            cycle_time: Duration::default(),
+        }
+    }
+}
+
+impl AppConfig for Config {
+    type Init = Self;
+
+    fn logging(&self) -> &str {
+        &self.log.level
+    }
+
+    fn logging_mod_levels(&self) -> Option<&HashMap<String, String>> {
+        Some(&self.log.modules)
+    }
+
+    fn pre_log_println(_message: String) {
+        // shut up
+    }
+
+    fn print_banner(_name: &str, _version: &str) {
+        // No banner
+    }
+
+    /// Add args to later use in the `configure` method.
+    fn add_args<'a: 'b, 'b>(clap: clap::App<'a, 'b>) -> clap::App<'a, 'b> {
+        // Default impl
+        clap
+            .arg(
+                clap::Arg::with_name("input")
+                    .value_name("FILE")
+                    .help("Program to run")
+                    .required_unless("default-config")
+                    .takes_value(true),
+            )
+            .arg(
+                clap::Arg::with_name("asm-only")
+                    .short("P")
+                    .long("asm")
+                    .help("Only assemble, do not run."),
+            )
+            .arg(
+                clap::Arg::with_name("cycle")
+                    .long("cycle")
+                    .short("C")
+                    .value_name("MICROS")
+                    .help("Cycle time (us, append \"s\" or \"ms\" for coarser time)")
+                    .takes_value(true),
+            )
+    }
+
+    fn configure(mut self, clap: &ArgMatches) -> anyhow::Result<Self> {
+        self.program_file = clap.value_of("input").unwrap().to_string();
+        self.assemble_only = clap.is_present("asm-only");
+        if let Some(t) = clap.value_of("cycle") {
+            let (t, mul) = if t.ends_with("us") {
+                (&t[..(t.len()-2)], 1)
+            } else if t.ends_with("ms") {
+                (&t[..(t.len()-2)], 1000)
+            } else if t.ends_with("m") {
+                (&t[..(t.len()-1)], 1000)
+            } else if t.ends_with("u") {
+                (&t[..(t.len()-1)], 1)
+            } else if t.ends_with("s") {
+                (&t[..(t.len()-1)], 1000_000)
+            } else {
+                (t, 1)
+            };
+
+            self.cycle_time = Duration::from_micros(t.parse::<u64>().expect("parse -C value") * mul);
+            println!("ct = {:?}", self.cycle_time);
+        }
+        Ok(self)
+    }
+}
+
+
+fn main() -> anyhow::Result<()> {
+    let config = Config::init("crsn", "crsn.json5", env!("CARGO_PKG_VERSION"))?;
+
+    debug!("Loading {}", config.program_file);
+
+    let source = read_file::read_file(&config.program_file)?;
+
+    let uniq = CrsnUniq::new();
+
+    let parsed = crsn::asm::assemble(&source, &uniq, vec![
+        ArithOps::new(),
+        BufOps::new(),
+        StdioOps::new(),
+    ])?;
+
+    if config.assemble_only {
+        for (n, op) in parsed.ops.iter().enumerate() {
+            println!("{:04} : {}", n, op.to_sexp());
+        }
+        return Ok(());
+    } else {
+        trace!("--- Compiled program ---");
+        for (n, op) in parsed.ops.iter().enumerate() {
+            trace!("{:04} : {}", n, op.to_sexp());
+        }
+        trace!("------------------------");
+    }
+
+    debug!("Start runtime");
+
+    let args = &[];
+    let thread = RunThread::new(ThreadParams {
+        id: ThreadToken(0),
+        uniq,
+        program: parsed,
+        pc: Addr(0),
+        cycle_time: config.cycle_time,
+        args
+    });
+
+    // run without spawning, so it is on the main thread - required by some extensions
+    thread.run();
+
+    debug!("Runtime shut down.");
+
+    Ok(())
+}
diff --git a/launcher_nox/src/read_file.rs b/launcher_nox/src/read_file.rs
new file mode 100644
index 0000000..5ac24b8
--- /dev/null
+++ b/launcher_nox/src/read_file.rs
@@ -0,0 +1,14 @@
+use std::fs::File;
+use std::io;
+use std::io::Read;
+use std::path::Path;
+
+/// Read a file to string
+pub fn read_file<P: AsRef<Path>>(path: P) -> io::Result<String> {
+    let path = path.as_ref();
+    let mut file = File::open(path)?;
+
+    let mut buf = String::new();
+    file.read_to_string(&mut buf)?;
+    Ok(buf)
+}
diff --git a/launcher_nox/src/serde_duration_millis.rs b/launcher_nox/src/serde_duration_millis.rs
new file mode 100644
index 0000000..306ad82
--- /dev/null
+++ b/launcher_nox/src/serde_duration_millis.rs
@@ -0,0 +1,18 @@
+use std::time::Duration;
+
+use serde::{self, Deserialize, Deserializer, Serializer};
+
+pub fn serialize<S>(value: &Duration, se: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+{
+    se.serialize_u64(value.as_secs() * 1000 + value.subsec_millis() as u64)
+}
+
+pub fn deserialize<'de, D>(de: D) -> Result<Duration, D::Error>
+    where
+        D: Deserializer<'de>,
+{
+    let s: u64 = u64::deserialize(de)?;
+    Ok(Duration::from_millis(s))
+}