---
title: "Kobako: Script Execution"
date: 2026-09-23T00:00:00+08:00
publishDate: 2026-09-23T00:00:00+08:00
lastmod: 2026-09-21T21:42:48+08:00
tags: ["LLM","AI","Experience","Ruby","WebAssembly","Gem","mruby"]
series: "kobako"
toc: true
aiTranslated: true
permalink: "https://blog.aotoki.me/en/posts/2026/09/23/kobako-script-execution/"
language: "en"
---


With [standard output](https://blog.aotoki.me/en/posts/2026/09/16/kobako-standard-output/) in place, whether a script runs correctly can finally be verified, which means we can start genuinely running Ruby inside Kobako's sandbox environment.

<!--more-->

## Interface Design{#interface-design}

At first I used `#run` to run a piece of Ruby script, but after a few times I started wondering whether that was really the right fit. If every time I want to run a script in Kobako I have to assemble a big chunk of code into it, does that really make sense?

For example, say I have a module I use a lot.

```ruby
module Formatter
  module_function

  def tag(name, *messages)
    "[#{name}] #{messages.join(" ")}"
  end
end
```

Running it through `#run` would look like this.

```ruby
sandbox.run(<<~SANDBOX)
module Formatter
  module_function

  def tag(name, *messages)
    "[#{name}] #{messages.join(" ")}"
  end
end

puts Formatter.tag("info", "Hello World")
SANDBOX
```

That means I have to "copy" the Formatter code every single time, which isn't convenient. After weighing it up, I decided to introduce the idea of `#preload`.

```ruby
sandbox.preload(<<~SANDBOX)
module Formatter
  module_function

  def tag(name, *messages)
    "[#{name}] #{messages.join(" ")}"
  end
end
SANDBOX

sandbox.run("puts Formatter.tag('info', 'Hello World')")
```

This way, there's no need to keep putting Formatter inside `#run` on every call — preloading it once with `#preload` is enough.

## The Snippet Mechanism{#snippet}

There is actually a perfectly good reason for designing it that way. Kobako only had `#run` to begin with because it was designed from a "disposable use" point of view.

```ruby
# Boilerplate
sandbox = Sandbox.new
# ...

# mrb_state (independent)
sandbox.run("true")

# mrb_state (independent)
sandbox.run("true")
```

A single `mrb_state` is a complete mruby VM (Virtual Machine), so two `#run` calls inherit nothing from each other, and that's exactly why Formatter has to be copied in on every execution.

For a sandbox meant to be used by AI this is a relatively clean approach, since nothing pollutes anything else and there is no leftover state, so problems are less likely to happen. In exchange, though, it isn't all that pleasant to use.

So `#preload` is actually very simple, and this is basically how it's implemented.

```ruby
class Sandbox
  attr_reader :snippets

  def initialize
    # ...
    @snippets = []
  end

  def preload(snippet)
    @snippets << snippet
  end

  def run(code)
    vm.run([*@snippets, code].join("\n\n"))
  end
end
```

As long as the code snippets to be preloaded are stored up front, they can be combined back together when it's actually time to execute, which achieves much the same effect.

## Run and Eval{#run-and-eval}

Once `#preload` was in place, I adjusted things again into two methods, `#eval` and `#run`, because that opens up more different ways to use it, such as mimicking Rack's design.

```ruby
sandbox.preload(<<~SANDBOX)
class MyApp
  def self.call(env)
    puts "Hello, #{env[:name]}"
  end
end
SANDBOX

sandbox.run(:MyApp, name: "Aotoki")
```

In turn, the original `#run` became `#eval`, which sits closer to the meaning of executing a piece of code.

```ruby
sandbox.eval(<<~SANDBOX)
  puts "Hello World"
SANDBOX
```

But this clearly leaves a problem. How do we let the `mrb_state` inside WebAssembly know that it should call `MyApp.call(...)`?

This is where the [WebAssembly memory exchange](https://blog.aotoki.me/en/posts/2026/08/19/kobako-webassembly-memory-exchange/) technique comes in, except the call travels in the opposite direction. Using WebAssembly's Host Function capability, a call to either `#run` or `#eval` is routed to a single "start the mruby environment" method so that everything is handled in one place.

Since this needs Rust's help, `#run` and `#eval` are in practice mapped to Ruby methods that share one `boot` method, which loads all the registered snippets in at once.

```rust
fn boot(snippets: &[Snippet]) -> Result<Mrb, Exc> {
    let mrb = Mrb::open();
    for s in snippets {
        mrb.load(&format!("(snippet:{})", s.name()), s.body());
        if let Some(e) = mrb.take_exception() { return Err(e); }
    }
    Ok(mrb)
}

fn outcome(mrb: &Mrb, value: Val) -> Outcome {
    match mrb.take_exception() {
        Some(e) => Outcome::Panic(e),
        None => Outcome::Value(mrb.to_wire(value)),
    }
}

fn eval(snippets: &[Snippet], source: &[u8]) -> Outcome {
    let mrb = match boot(snippets) { Ok(m) => m, Err(e) => return Outcome::Panic(e) };

    let value = mrb.load("(eval)", source);
    outcome(&mrb, value)
}

fn run(snippets: &[Snippet], target: &str, args: Vec<Val>, kwargs: Option<Val>) -> Outcome {
    let mrb = match boot(snippets) { Ok(m) => m, Err(e) => return Outcome::Panic(e) };

    let Some(entrypoint) = mrb.top_level_const(target) else {
        return Outcome::Panic(Exc::sandbox_error(format!("undefined entrypoint: {}", target)));
    };
    if !mrb.respond_to(&entrypoint, "call") {
        return Outcome::Panic(Exc::sandbox_error(format!("entrypoint {} does not respond to :call", target)));
    }

    let mut argv = args;
    argv.extend(kwargs);
    let value = mrb.funcall(&entrypoint, "call", &argv);
    outcome(&mrb, value)
}
```

What sets `#run` apart from `#eval` is that it goes straight through mruby's C API to check whether the object the user named has a callable (`#call`) method. If it does, the arguments handed in by the host (Host) are converted into a format mruby can understand and passed along.

So far there hasn't been anything especially complex or difficult in Kobako's design, as it's all built on familiar mechanisms like method calls and storing variables. But it's exactly this step-by-step evaluation of every use case that has gradually turned it into a sandbox that can actually be used.

