DEV Community

Cover image for Self-Evolving Apps: Not Vibe, Live-Coding in Ruby with LLMs and Metaprogramming
Onur Cinar
Onur Cinar

Posted on • Originally published at zdo.com

Self-Evolving Apps: Not Vibe, Live-Coding in Ruby with LLMs and Metaprogramming

Using AI to generate code for a new application is a familiar workflow today. But what if an application starts as a completely blank slate, learning on the job and writing its own implementation live as you call nonexistent methods?

This concept of live-patching and zero-downtime execution isn't entirely new. Early in my career at Nortel Networks, I have seen this with PROTEL (PRocess Oriented TELepony language), a proprietary language designed for telecom switches. To achieve "five nines" (99.999%) availability, you couldn't simply take systems offline for deployments—code updates had to happen via live hot-patching. Later on, I thoroughly enjoyed similar live code reloading capabilities while working with Erlang.

Recently, as I spent more time with Ruby, its rich metaprogramming capabilities got me thinking: What if we start with an empty Ruby object, and as we call methods on it, it uses an LLM to write and evaluate its own code on the fly?


1. The Blank Slate

Let's start with a completely empty class:

class Dummy < LiveCode
end
Enter fullscreen mode Exit fullscreen mode

Now, let's fire up irb (Ruby's REPL) and start interacting with our dummy object as if the methods already existed:

dummy = Dummy.new
dummy.add(1, 2)
Enter fullscreen mode Exit fullscreen mode

At this point, Ruby will complain because Dummy doesn't have an add method. However, the method name (add) and its arguments (1, 2) clearly communicate our intent.

Ruby provides a built-in hook called method_missing to catch calls to undefined methods. This is where we bring in AI. To interact with our LLM provider, we'll use the excellent RubyLLM gem.


2. Dynamic Method Generation via method_missing

To make this behavior reusable across objects, we put our logic inside a LiveCode base class that Dummy inherits from.

Here is our initial LiveCode implementation:

class LiveCode
  def method_missing(method_name, *args, **kwargs)
    p "Missing method: #{method_name}, #{args.inspect}, #{kwargs.inspect}"

    prompt = <<~PROMPT
      You are a Ruby code generator. A missing method `#{method_name}` was called
      with the arguments: #{args.map(&:class)}, keyword arguments: #{kwargs.keys}.

      Return ONLY valid Ruby code defining this method. Do not include markdown formatting.
      Example:
      def #{method_name}(...)
        # implementation
      end
    PROMPT

    ruby_code = @chat.ask(prompt).content

    # Evaluate the generated Ruby code directly on the instance's singleton class
    singleton_class.class_eval(ruby_code)

    # Re-dispatch the original method call now that it exists!
    send(method_name, *args, **kwargs)
  end
end
Enter fullscreen mode Exit fullscreen mode

How It Works

  1. Interception: When dummy.add(1, 2) is called, method_missing intercepts the call and extracts the method name (:add) and parameter types (Integer, Integer).
  2. LLM Prompting: We construct a prompt instructing the model to return only valid Ruby code defining the method.
  3. Metaprogramming: We use singleton_class.class_eval(ruby_code) to inject the generated method into our object at runtime.
  4. Re-dispatch: Finally, send(method_name, ...) invokes the newly defined method seamlessly!

3. Seeing It in Action

Let's test this in IRB:

irb(main):003> dummy.add(1, 2)

"Missing method: add, [1, 2], {}"

"Prompt: You are a Ruby code generator. A missing method `add` was called
with the arguments: [Integer, Integer], keyword arguments: [].

Return ONLY valid Ruby code defining this method. Do not include markdown formatting.
Example:
def add(...)
  # implementation
end
"

"Code: def add(a, b)
  a + b
end"

=> 3
Enter fullscreen mode Exit fullscreen mode

In real-time, the LLM synthesized def add(a, b); a + b; end, registered it on dummy, executed it, and returned 3. Subsequent calls to dummy.add(1, 2) will execute instantly without hitting method_missing again!


4. Adding Context: State and Inter-Method Dependencies

A real object has multiple methods that need to share state via instance variables and interact with one another. To enable this, our LLM needs context about existing instance variables, their types, and previously generated methods.

We introduce a helper method llm_context to capture this state:

def llm_context
  context = "# Current Instance Variables and Types:\n"
  if instance_variables.empty?
    context += "(No instance variables yet)\n"
  else
    instance_variables.each do |name|
      value = instance_variable_get(name)
      context += " - #{name}: #{value.class.name}\n"
    end
  end

  context += "# Previously Generated Methods:\n"
  if @_generated_methods.nil? || @_generated_methods.empty?
    context += "(No generated methods yet)\n"
  else
    @_generated_methods.each do |name, code|
      context += "#{code}\n"
    end
  end

  context
end
Enter fullscreen mode Exit fullscreen mode

We then update our prompt to include this rich context:

prompt = <<~PROMPT
  You are a Ruby code generator. A missing method `#{method_name}` was called
  with the arguments: #{args.map(&:class)}, keyword arguments: #{kwargs.keys}.

  Write ONLY the valid Ruby code to define `#{method_name}`. Ensure it works
  well with the existing instance variables and previously generated methods.

  #{llm_context}

  Return ONLY valid Ruby code defining this method. Do not include markdown formatting.
  Example:
  def #{method_name}(...)
    # implementation
  end
PROMPT
Enter fullscreen mode Exit fullscreen mode

5. Stateful Walkthrough: Setters & Getters

Let's test setting a property and then retrieving it in a subsequent call.

Step 1: Setting a Value

irb(main):003> dummy.name = "Onur"

"Missing method: name=, [\"Onur\"], {}"
"Prompt: You are a Ruby code generator. A missing method `name=` was called
with the arguments: [String], keyword arguments: [].

Write ONLY the valid Ruby code to define `name=`. Ensure it works
well with the existing instance variables and previously generated methods.

# Current Instance Variables and Types:
 - @chat: RubyLLM::Chat
# Previously Generated Methods:
(No generated methods yet)

Return ONLY valid Ruby code defining this method. Do not include markdown formatting.
Example:
def name=(...)
  # implementation
end
"

"Code: def name=(value)
  @name = value
end"

=> "Onur"
Enter fullscreen mode Exit fullscreen mode

Calling dummy.name = "Onur" dynamically created the setter method name=(value) which initialized the @name instance variable.

Step 2: Reading the Value

irb(main):004> dummy.name

"Missing method: name, [], {}"
"Prompt: You are a Ruby code generator. A missing method `name` was called
with the arguments: [], keyword arguments: [].

Write ONLY the valid Ruby code to define `name`. Ensure it works
well with the existing instance variables and previously generated methods.

# Current Instance Variables and Types:
 - @chat: RubyLLM::Chat
 - @_generated_methods: Hash
 - @name: String
# Previously Generated Methods:
def name=(value)
  @name = value
end

Return ONLY valid Ruby code defining this method. Do not include markdown formatting.
Example:
def name(...)
  # implementation
end
"

"Code: def name
  @name
end"

=> "Onur"
Enter fullscreen mode Exit fullscreen mode

Because our prompt included @name: String and the previously defined name=(value) method, the LLM understood the context and generated the exact matching getter method def name; @name; end.


What's Next?

This experiment demonstrates how easily Ruby's dynamic nature combines with LLMs to build self-assembling objects. There are a few natural next steps for expanding this idea—such as persisting the generated code to disk, adding sandboxing/security checks, or enabling feedback loops to auto-fix runtime errors.


Source Code & Examples

The complete working source code for this post is available in the site repository:

https://zdo.com/blog/posts/self-evolving-app-code/

Top comments (0)