POST Your Program

· 8 min read

POST Your Program

What if, instead of designing an API, you just let clients send programs?

curl -X POST --data-binary '~add(a: 3, b: 4)
| sum s |> add(a: s, b: 10)
    | sum s2 |> result { value: s2 }' http://localhost:3000/eval
{"result":{"branch":"result","fields":{"value":17}},"budget_used":4}

That’s not JSON-RPC. That’s not GraphQL. The client sent Koru source code, including the return shaperesult { value: s2 } is a branch constructor that tells the interpreter what to give back. The server parsed it, dispatched two compiled function calls (3+4=7, then 7+10=17), tracked that they cost 4 budget units (2 each), and returned the client-specified result as JSON.

This is experimental. And it might be the most interesting thing we’ve built.

The Server

65 lines of Koru:

~import "$orisha"
~import "$std/runtime"
~import "$std/io"
~import "$std/fmt"

// Two compiled events - real Zig functions, not interpreted
~pub event greet { name: []const u8 }
| greeted []const u8

~greet = std.fmt:ln("Hello, {{name:s}}!")
| line l |> greeted l.text

~pub event add { a: i64, b: i64 }
| sum i64

~add = sum a + b

// Register them as callable with costs
~std.runtime:register(scope: "api") {
    greet(5)   // 5 budget per call
    add(2)     // 2 budget per call
}

// Route POST /eval to the interpreter
~orisha:handler = orisha:router(req)
| [GET /] |> response { status: 200, body: "POST Koru source to /eval", content_type: "text/plain" }
| [POST /eval] _ |> std.runtime:run(source: req.body orelse "", scope: "api", budget: 1000)
    | result r |> std.fmt:ln("{\"result\":{{ r.value.toJsonBuf():s }},\"budget_used\":{{ r.used }}}")
        | line l |> response { status: 200, body: l.text, content_type: "application/json" }
    | exhausted e |> std.fmt:ln("{\"error\":\"budget_exhausted\",\"used\":{{ e.used }}}")
        | line l |> response { status: 429, body: l.text, content_type: "application/json" }
    | parse_error e |> std.fmt:ln("{\"error\":\"parse_error\",\"message\":\"{{ e.message:s }}\"}")
        | line l |> response { status: 400, body: l.text, content_type: "application/json" }
| [*] |> response { status: 404, body: "{\"error\":\"not_found\"}", content_type: "application/json" }

~orisha:serve(port: 3000)
| shutdown _ |> _
| failed f |> std.io:println(text: f.msg)

koruc main.kz && ./a.out. That’s it.

What’s Actually Happening

When a POST hits /eval, the request body is Koru source code. The server:

  1. Parses it with a lightweight flow parser (~3,000 ns — no compiler infrastructure)
  2. Resolves event names against the registered scope ("api")
  3. Dispatches to compiled Zig functions (not interpreted implementations)
  4. Follows the continuation chain the client specified
  5. Tracks budget across all dispatches
  6. Returns the final result as JSON

The business logic is compiled. The event implementations (~add = sum a + b) are native Zig. Only the flow — which events to call and how to wire their outputs — is interpreted. The interpreter is a router for compiled code.

The Client Controls the Flow

This is the part that matters. Look at the nested add:

~add(a: 3, b: 4)
| sum s |> add(a: s, b: 10)
    | sum s2 |> result { value: s2 }

The client is saying: “Call add with 3 and 4. Take the sum, call add again with that sum and 10. Construct a result with the final value.”

The result { value: s2 } at the end is a branch constructor — the client explicitly defines the shape of what comes back. The server doesn’t have a “nested add” endpoint. It has add. The client composed two calls with a continuation chain and specified the return shape. The server just followed the instructions.

Different client, different flow, same server:

~greet(name: "World")
| greeted g |> result { message: g }
{"result":{"branch":"result","fields":{"message":"Hello, World!"}},"budget_used":5}

The client picks which events to call, in what order, what to do with the results, and what shape to return. The server enforces the budget.

The Numbers (Honest)

wrk -t4 -c100 -d10s (nested add, POST with body parsing + interpreter)

Requests/sec:   116,604
Avg Latency:    838µs

For context, the same Orisha framework does 150,000 req/s on compiled routes. The interpreter penalty is 1.2x — barely measurable.

This was not always the case. The first version used the full compiler parser — the same 7,263-line parser that handles event declarations, proc bodies, imports, type registries, and module resolution. That version did 2,836 req/s, a 50x penalty. Then we built a purpose-built flow parser: 713 lines, no TypeRegistry, no ModuleResolver, no ErrorReporter. Just parse the flow and go. We also replaced a page_allocator-based value clone (which was doing mmap syscalls per field and leaking memory) with a thread-local fixed buffer.

The result: each interpreter call takes ~3,200 nanoseconds. That’s 1.6x faster than Python’s eval() on the same workload. The business logic (compiled Zig) runs in ~60 ns. The flow parser takes ~3,000 ns. Everything else — validation, environment setup, dispatch, result cloning — is under 100 ns combined.

116,000 sandboxed evals per second. Budgeted, scoped, with continuation-based flow control. On a laptop.

What This Is Not

This is not production-ready. There’s no authentication, no TLS, no persistent state between requests. But the interpreter is fast enough that performance is no longer the concern — the missing pieces are operational, not computational.

This is a proof of concept for an idea: event continuations as the wire protocol.

What This Might Be

Traditional APIs have a fixed surface. You design endpoints, the client calls them, the server responds. GraphQL loosened this - the client specifies the shape of the response. But the operations are still server-defined.

Here, the client defines the flow and the return shape. The server defines the vocabulary (registered events with costs) and the limits (budget). Everything in between is the client’s choice.

For LLM tool calling, this is interesting. Instead of defining a rigid tool schema, you give the LLM a set of events and a budget. It writes the flow. The budget prevents runaway execution. Auto-discharge (from the budgeted interpreter) cleans up resources if budget runs out mid-flow.

For multi-tenant platforms, it’s a different proposition. Each tenant gets a scope and a budget. They compose available events however they want. You don’t need to anticipate every workflow - you provide primitives and let users compose them.

Curating the API Surface

The example above uses local events — greet and add — so the names are already clean. Real applications have module structure:

~import "$app/db/user"
~import "$app/auth"

Without aliasing, you’d register the full paths and clients would have to write ~app.db.user:get(id: 42). That leaks implementation details into the wire protocol.

The -> alias fixes this:

~std.runtime:register(scope: "api") {
    greet(5)
    add(2)
    app.db.user:get   -> get_user(10)
    app.db.user:list  -> list_users(3)
    app.auth:login    -> login(20)
}

The client sees: greet, add, get_user, list_users, login. A flat namespace. No modules, no colons, no dots.

The same internal event can have multiple aliases with different costs:

~std.runtime:register(scope: "api.v2") {
    app.db.user:get -> get_user(10)
    app.db.user:get -> peek_user(2)    // same event, cheaper
}

The alias replaces — clients can call ~get_user(...) but not ~app.db.user:get(...). The server controls exactly what’s exposed.

Combined with named scopes, this gives you three layers of indirection — all compile-time verified:

LayerControlsClient sees?
ScopeWhich API version/tierNo (server chooses)
AliasPublic operation namesYes (what they type)
Internal pathActual implementationNever

API versioning is one line: change scope: "api.v1" to scope: "api.v2". Client code doesn’t change. Internal module structure doesn’t change. Only the scope registration block — the contract — evolves.

The Architecture

Client                          Server
  |                               |
  |  POST /eval                   |
  |  Body: ~add(a: 3, b: 4)      |
  |        | sum s |>             |
  |          add(a: s, b: 10)    |
  |          | sum s2 |>         |
  |          result { value: s2 } |
  |------------------------------>|
  |                               |  1. Parse Koru source
  |                               |  2. Resolve "add" in scope "api"
  |                               |  3. Dispatch to compiled add(3, 4) [cost: 2]
  |                               |  4. Follow continuation: sum s
  |                               |  5. Dispatch to compiled add(7, 10) [cost: 2]
  |                               |  6. Follow continuation: sum s2
  |                               |  7. Construct result { value: 17 }
  |<------------------------------|
  |  {"result":{"branch":"result",|
  |    "fields":{"value":17}},    |
  |   "budget_used":4}            |

The interpreter walks the flow. Each ~event(...) dispatches to the compiled implementation. Each | branch binding |> is a continuation that routes the output to the next step. The final branch constructor (result { value: s2 }) tells the interpreter what to return — the client defines the response shape. Budget is decremented at each dispatch.

If budget runs out, execution stops and undischarged handles are cleaned up automatically. The client gets an exhausted response with how much was used and which event was the last straw.

Try It

# Clone and build
cd examples/interpreter-server
koruc main.kz && ./a.out

# Simple call — branch constructor defines the return shape
curl -X POST --data-binary $'~greet(name: "World")\n| greeted g |> result { message: g }' localhost:3000/eval

# Nested calls — sub-continuations are indented
curl -X POST --data-binary $'~add(a: 3, b: 4)\n| sum s |> add(a: s, b: 10)\n    | sum s2 |> result { value: s2 }' localhost:3000/eval

# Parse error
curl -X POST -d 'not valid koru' localhost:3000/eval

# Budget exhaustion (with very low budget - future: X-Budget header)
# For now, budget is hardcoded at 1000 in the server source

It works today. The interpreter is fast today. The architecture won’t need to change.


Source: examples/interpreter-server in the Orisha repo. Built on the budgeted interpreter and the router that doesn’t exist.