The Construction of Blimp

March 21, 2026
tl;dr

Feeling the Grammar

We built a parser in Zig with a tree-sitter grammar. The syntax is not hypothetical. This post walks through every construct as it exists right now, writing real programs to see how the language feels. Actors, typed state, pipes, message sends, guards, Holes, situations, bubbles. All of it parses. Some of this will change. We're nailing the good parts early.

Building Blimp, the first piece I wanted to really feel out was the grammar. As someone who has spent 20 years in or adjacent to Ruby, it feels natural to start there and really suss things out in order to ensure that it feels natural to write. Yes, AIs are going to be cranking out a lot of this. We all know that. But here's the thing: you are still the one sketching. You are still the one who looks at what the agent wrote and goes "no, not like that, like this." You are the driver even when you're orchestrating AI at a mass scale, defining the responsibilities, drawing the boundaries of the system. The human holds the pen even when the robots are holding all the other pens. So the grammar has to feel good in your hand, not just parse correctly in a machine.

There will be riffing, there will be notes to the robot and sketches done, there will be some real action involving the lang.

So, we want it to feel good.

To begin with, we built a parser. It is written in Zig with a tree-sitter grammar that mirrors it and it can parse real Blimp code into an AST right now. There's a full corpus of test cases that exercises every construct you'll see in this post. This is not hypothetical syntax, it's running code.

And all of this ties into the tooling. Everything is powered by a diff viewer that follows the work as a multiplexer. You can drop messages to sessions trivially, each agent sitting there in its own pane. The diffs are semantic, not line-by-line text diffs, structural diffs that know a function moved, not that 30 lines were deleted and 30 were added somewhere else. Time travel works at two levels: version control gives you the history of your source, and the debugger gives you the history of your system's state itself because every become is a snapshot. You can scrub through both. The language works reflexively, it can describe its own behaviors, its own flow, its own contracts and boundaries while also running them. The tools and the language are the same thing.

This sounds crazy but I already have the diff viewer with the multiplexer in a functional state. It follows files, it stages, it commits, it has keyboard nav and follow mode. The multiplexer runs up to four Claude sessions side by side in a split-pane grid. We're using these tools with Claude to build this, but later it'll be agents specially trained on Blimp itself as we implement more and more of it and start writing libraries and the standard actors and all the rest. We're building the tool that builds the language that defines the tool. It's turtles all the way down and I'm having a great time.

So with that, let me walk through every piece of the grammar as it sits right now. I'm writing out programs to feel how they work. A good bit of this will change, but we're going to try to nail "the good parts" early.

Actors

Actors hold state and reply to messages. PascalCase names, do/end blocks. They are the most important primitive we have. Everything is an actor.

Actors are the most important primitive we have. There's no separate concept of "pure functions" or "helper modules." Some actors hold state and handle many messages. Some are tiny and do one thing.

You define them with actor and they come in PascalCase. Lowercase names are always variables. This distinction just keeps things simple and clear. System is the root actor that provides the primitives, math, IO, strings. You're always inside System.

actor Counter do
  state count: Int :: 0

  on :increment do
    become count: count + 1
    reply :ok
  end
end

Typed State

State fields require types. state name: Type :: default. The :: separates type from default value. Types are rigid. The compiler catches mistakes, not the runtime.

State fields have required types. The syntax is state name: Type :: default_value.

The : separates the field name from its type. :: separates the type from the default value. [Item] for lists, %{Atom => Float} for maps.

Types are rigid. The compiler catches mistakes, not the runtime. And because of that type safety, notice there's no {:ok, value} tuple on the reply. If the handler ran at all, the types matched. If someone sends a :deposit with a String, the message just doesn't match, it never enters the do block. So reply balance + amount is enough. No wrapping needed.

actor BankAccount do
  state balance: Int :: 0
  state owner: String :: "unknown"
  state ledger: [Transaction] :: []
  state frozen: Bool :: false

  on :deposit(amount: Int) do
    become balance: balance + amount
    reply balance + amount
  end
end

Pipes

The |> operator chains transformations. _ marks where the piped value goes. Left-associative, works with any expression on the right side. Feels like Elixir but with explicit placement.

Fuck yes I need my pipes. The |> operator chains transformations and _ marks where the piped value goes.

This is like Elixir's pipes but with explicit argument placement. In Elixir, the pipe always inserts as the first argument. In Blimp, _ tells you exactly where the value goes. That's a small thing but it matters when you're reading code, there's never a question about which argument position gets the piped value.

If the right side has no parens, it's just called as a no-arg function with the piped value: items |> sort |> reverse.

Pipes are left-associative, so a |> b |> c is (a |> b) |> c. They sit at a specific precedence level: lower than binary ops, higher than orelse, lower than message sends. So x |> f orelse :default means (x |> f) orelse :default, which is what you'd expect.

receipt = items
  |> calculate_tax(_, region)
  |> apply_discount(_, code)
  |> finalize(_, payment)

Message Send

Actors talk to each other with <-. No PIDs, no process registry. Sibling actors address each other by name. Dot-notation names like Shop.Checkout define the supervision hierarchy.

The <- operator sends a message to an actor and waits for the reply.

No PIDs, no process registry. Siblings address each other by name. The name IS the address. Shop.Checkout means Checkout supervised by Shop. That dot notation is not a module path, it's the actual supervision hierarchy. The namespace is the runtime topology.

Message send sits at a specific precedence: higher than pipes, lower than binary operators. So you can pipe into a send target but you don't accidentally capture a binary expression as the target. This precedence chain, orelse > pipe > send > binary ops, makes complex expressions read naturally without extra parens.

# Send a message, get the reply
Shop.Checkout <- :add(%{name: "shirt", price: 29.99})

# Use the reply in a variable
count = Shop.Inventory <- :check("shirt")

# Message send with orelse for failure recovery
receipt = Shop.Checkout <- :charge(payment) orelse bubble

Guards

when guards filter on message parameters. Handlers only fire if the guard passes. Combined with typed params, you get very precise dispatch without nested conditionals.

Handlers can have when guards that filter on the parameters.

The handler only fires if the guard expression evaluates to true. Combined with typed params, you get very precise dispatch. Two handlers for the same message with different guards, the runtime picks the first that matches. No nested if inside case inside handler body. Just separate handlers with clear conditions.

Guards can use any expression, including function calls. Notice the when and bubbles() can coexist on the same handler. The order is always: message name, params, guard, bubble strategy, do block.

actor Account do
  state balance: Int :: 0

  on :withdraw(amount: Int) when amount > 0 do
    become balance: balance - amount
    reply balance - amount
  end

  on :withdraw(amount: Int) when amount <= 0 do
    reply :error
  end
end

# Guards + bubbles on same handler
on :charge(payment: Payment)
    when valid?(payment)
    bubbles(CascadeBubble) do
  # only runs if valid?(payment) is truthy
end

Holes

The _ operator creates a Hole. A Hole is not a wildcard. It's a typed gap in your program that an agent fills. It acts as identity until filled so your program keeps running. The comment after a Hole is a directive to the agent. Inside pipes, _ is filled by the piped value. Elsewhere it's an agent Hole. Context resolves the ambiguity.

_ creates a Hole. And a Hole is not a wildcard. It is not a discard. It's not what you're used to from Elixir or Haskell.

A Hole is a typed gap in your program. The compiler knows about it. The agent knows about it. The canvas renders it as unfinished space. And until the agent fills it, it acts as identity, so your program keeps running.

That comment after the Hole is not a note to yourself. It is a directive to the agent. The agent reads it, goes and does the research, and comes back with suggestions to fill the Hole. The comment IS the prompt.

An agent is always pairing with you in this runtime. The Hole is how you talk to it without leaving your code. You don't switch to a chat window. You don't write a prompt in some sidebar. You write _ and a comment, and the agent sees it in the tree-sitter AST and gets to work.

on :charge(payment: Payment)
    bubbles(CascadeBubble) do
  situation validate(payment) do
    :valid ->
      receipt = finalize(items, total, payment)
      become items: [], total: 0.0
      reply receipt
    _ # Hole: handle invalid payment,
      # begin by researching documentation
      # on transaction failure
  end
end

Inside a pipe, _ means something different. Same syntax, different meaning, zero ambiguity. A Hole in a pipe has a value flowing into it. A Hole in a situation doesn't. Context does the work.

# Holes filled by the pipe
items
  |> calculate_tax(_, region)
  |> apply_discount(_, code)
  |> charge(payment)

# Hole for the agent
situation payment do
  :valid -> charge(payment)
  _ # Hole: the agent sees this
end

Situations

situation is like case but allows Holes. A case must be exhaustive. A situation can have gaps that the agent helps fill. It's branching where ambiguity is explicitly allowed and the robot participates in resolving it.

situation is how you branch when you're not sure of everything yet.

It looks like case. But a case is a finished thought, every branch accounted for. A situation can have Holes. That's the whole point. You write the branches you know and the agent helps you figure out the rest.

The agent sees the situation, sees the Hole, looks at the type of balance - amount, and suggests what should happen when the result is negative. Maybe it suggests bubbling. Maybe it suggests a retry. Maybe it looks at your BankAccount actor and notices you have a frozen state field you haven't used yet and asks if that's relevant.

When you eventually fill every Hole in a situation, you can promote it to a case. That's a signal to the compiler: "this is done, enforce exhaustiveness from now on."

on :withdraw(amount: Int) do
  situation balance - amount do
    n when n >= 0 ->
      become balance: n
      reply n
    _ # Hole: handle insufficient funds
  end
end

Bubbles

Bubbles are failure actors. When something goes wrong, a Bubble actor spawns and walks up the supervision tree deciding who dies. Different Bubble actors have different blast radii. orelse catches bubbles on the caller side.

Failure in Blimp is handled by bubbles. And bubbles are actors.

When something goes wrong, you call bubble and a Bubble actor spawns. That actor walks up the supervision tree and decides who dies. Different Bubble actors have different blast radii.

You declare the bubble strategy per handler, not per actor, because different messages can have different failure semantics. A :charge failure is catastrophic, cascade everything. An :add_item failure is just me, restart and retry.

actor SelfBubble do
  # I die, my supervisor restarts me. Siblings untouched.
  on :propagate(supervisor: Actor) do
    supervisor <- :restart(origin)
  end
end

actor CascadeBubble do
  # I die and take my siblings with me. One-for-all.
  on :propagate(supervisor: Actor) do
    supervisor <- :restart_all
  end
end

On the caller side, orelse catches bubbles. This collapses Erlang's one_for_one, one_for_all, rest_for_one into syntax that lives right where the failure happens instead of in some separate supervisor config. And because Bubbles are actors, you can write custom ones that log before dying, notify an external system, or attempt recovery before giving up.

The inspiration here comes from Temper, which has a beautiful error model where bubble() signals failure with no payload, orelse recovers, and the type system tracks it all. We took that and made the bubbles themselves actors, because in Blimp everything is an actor.

on :charge(payment: Payment)
    bubbles(CascadeBubble) do
  situation process_payment(payment) do
    receipt when valid?(receipt) ->
      become items: [], total: 0.0
      reply receipt
    _ -> bubble reason: "payment failed"
  end
end

# On the caller side
receipt = checkout <- :charge(my_payment)
    orelse bubble

No Shared Memory

Actors don't share memory. Communication is message-passing only. This is a ballsy move without the Erlang VM but it means no locks, no mutexes, no data races. Each actor gets its own memory arena. When it dies, free everything. Messages are copied across boundaries. We keep the spirit of the BEAM.

Actors don't share memory. Each actor owns its state, period. Communication is only through messages.

This is a ballsy move without the Erlang VM. The BEAM gives you preemptive scheduling of millions of lightweight processes for free and we're compiling to WASM. So we need a green thread scheduler in userland, something that multiplexes many actors onto few real threads, with mailboxes and message passing.

But Perceus RC actually helps here. Because actors don't share memory, each actor's heap is independent. When an actor dies, you free its entire arena. No cross-actor GC coordination. Messages between actors are the only thing that crosses boundaries, and those are copied.

The spirit of the BEAM without the BEAM:

That's the become keyword paying off again. Because state transitions are immutable snapshots and nobody else is reading your state while you're transitioning it, there is simply nothing to lock.

Putting It Together

A complete system with typed state, guards, pipes, message sends, situations with Holes, bubbles, and dot-notation supervision. The whole thing. Then: the open questions, the canvas, jazz computers, jazz cigarettes.

Here's a complete example showing how namespacing IS the supervision tree. Shop.Checkout means Checkout is supervised by Shop. Each actor is defined at the top level with dot notation. Typed state, guards, pipes, message sends, situations, Holes, bubbles, all of it together:

actor Shop do
  state region: Atom :: :us
end

actor Shop.Checkout do
  state items: [Item] :: []
  state total: Float :: 0.0

  on :add(item: Item) do
    become items: [item | items],
           total: total + item.price
    reply length(items)
  end

  on :charge(payment: Payment)
      when valid?(payment)
      bubbles(CascadeBubble) do
    receipt = items
      |> calculate_tax(_, region)
      |> finalize(_, payment)
    become items: [], total: 0.0
    reply receipt
  end

  on :charge(payment: Payment)
      bubbles(CascadeBubble) do
    situation validate(payment) do
      :valid ->
        receipt = items
          |> calculate_tax(_, region)
          |> finalize(_, payment)
        become items: [], total: 0.0
        reply receipt
      _ # Hole: handle invalid payment,
        # begin by researching documentation
        # on transaction failure
    end
  end
end

actor Shop.Inventory do
  state stock: %{String => Int} :: %{}

  on :check(item_name: String) do
    reply lookup(stock, item_name)
  end
end

Shop.Checkout and Shop.Inventory are siblings. Shop supervises both. If Checkout's :charge handler bubbles with CascadeBubble, Inventory goes down too because they're siblings under the same supervisor. The namespace IS the blast radius.

Notice the two :charge handlers. The first has a when valid?(payment) guard, the happy path. The second doesn't, it's the catch-all with a situation and a Hole. Guards give you dispatch without nesting. The agent sees the Hole in the second handler and starts thinking about how to handle invalid payments while you keep writing the rest of the system.

And talking to it:

# Siblings address each other by name
Shop.Checkout <- :add(%{name: "shirt", price: 29.99})
Shop.Checkout <- :add(%{name: "hat", price: 15.00})

# Check stock before charging
count = Shop.Inventory <- :check("shirt")

# Charge with orelse for recovery
receipt = Shop.Checkout <- :charge(my_payment) orelse bubble

What We Haven't Figured Out Yet

This is still wet concrete. Here's what's open:

Pattern matching in handlers. Right now on :add(item: Item) just binds item. We want destructuring like on :add(%{price: price, name: name}: Item) but haven't nailed the syntax for how patterns and types coexist.

The scheduler. No shared memory means we need green threads in WASM. The BEAM does preemptive scheduling with reduction counting. We'll probably start with cooperative yields at message receive points and see how far that gets us.

And the canvas: what even is a canvas painting a program? Well we have a flow-down diagram to start, and there are messages flying, that gives you lines to draw. Its abstract but can be definitively geometric. Where there is a Hole, we can allow randomness, or fractals, or noise, or whatever else, and some means to click around and explore all this and see the system in its "living" state. Why not man? Its cool to look at weird abstract shit when you're just hanging out with a machine riffing like this doing jazz computers while you smoke some jazz cigarettes.

Next time we'll get into actual typing. I am only reaching for set theoretic and Perceus RC because I am adjacent to thinking about those two a lot lately. The process here surely will yield me some interesting results that probably will make me seriously consider other directions. We will see! Its a journey, baby.