Tep
A Sinatra-flavoured web framework that compiles to a native binary via Spinel.
Current release: v0.11.0 — now on RubyGems (
gem install tep): TLS, HTTP caching, connection pooling, PG raise-on-error + embeddings, on top of the agentic surface (Auth, Broadcast, Presence, LiveView, MCP). Pre-alpha; API still in motion.Why Tep exists. Two complementary goals:
Exercise Spinel against real-world Ruby code. Tep is the largest pure-Ruby application Spinel compiles end-to-end. Every Sinatra idiom, every battery, every demo doubles as a torture test for the AOT compiler's codegen + analyzer. Bugs surface here, get reduced to minimal repros, and land upstream as PRs or issues against matz/spinel. If something doesn't work in Tep, the bug is usually in Spinel, and that's the point.
Be the harness for toy. Toy is Tep's sibling project: a machine-learning framework written in pure Ruby and compiled by Spinel. Toy needs an HTTP/MCP layer for serving models, exposing training tools to agents (Claude Code et al.), streaming inference results, and wiring presence into collaborative training sessions. Tep is that layer. Every battery in Tep is shaped by what Toy actually needs to ship.
Tep happens to be useful as a general web framework too — fast, single-binary, Sinatra-shaped — but the design choices are made through these two lenses.
Quick start
A new project (the simple path). The Spinel toolbelt (bundler-spinel) scaffolds a project and provisions the Spinel compiler for you:
gem install bundler-spinel
spinel-compat init my_app && cd my_app
./bin/build # ensures tep, vendors deps, provisions Spinel, compiles → ./app
./app -p 4567
spinel-compat init writes a Gemfile (gem "tep"), an app.rb, and a
bin/build. The build step provisions a pinned Spinel compiler (cached under
~/.cache/spinel), vendors dependencies where Spinel can follow them, and
compiles app.rb to a native binary — no Ruby runtime in the result.
Your app.rb is plain Sinatra-flavoured Ruby:
require 'sinatra'
get '/' do
"hello from Tep"
end
get '/hi/:name' do
"hi, " + params[:name] + "!"
end
Already have a Sinatra app? Scaffold a project and use your source as its
app.rb — the build reports any Spinel-incompatible patterns:
gem install bundler-spinel
spinel-compat init my_app && cd my_app
cp /path/to/your_app.rb app.rb
./bin/build # → ./app (or a clear report of what won't translate)
The tep translator is also on RubyGems on its own (gem install tep) for use
in your own build scripts, once a Spinel engine is on $SPINEL / PATH.
From source (hacking on tep itself, or no bundler)
```sh # 1. Spinel (the AOT compiler) git clone https://github.com/matz/spinel && cd spinel && make all export PATH="$PWD:$PATH" # 2. tep git clone https://github.com/OriPekelman/tep && cd tep && make all ./examples/hello -p 4567 # your own app tep build hello.rb # → ./hello (~80 KB binary, no Ruby runtime) ```The translator (bin/tep) needs CRuby >= 3.2 with the prism gem
installed (a development-only dependency — bundle install brings it
in). Prism ships with the stdlib from 3.4 onward; on 3.2/3.3 it's a
gem install. Recommended Ruby manager:
rv — fast version+gem
manager from the Spinel Cooperative (separate project from the
matz/spinel AOT compiler Tep compiles through; same Ruby
neighbourhood). .ruby-version in this repo pins 3.4.0; rv
shell makes rv run rake test just work. Build deps on Linux:
build-essential, libsqlite3-dev. macOS: Xcode CLI tools.
For a full walkthrough — auth, persistence, deploy — see the Getting started wiki page.
Is it fast?
Yes. ~150k req/s on a small Linux server with the request path doing
actual work (SQLite SELECT + JSON), microsecond median latency. The
hot path is C from end to end (epoll, request parsing, dispatch,
response writer); the Ruby you wrote is compiled, not interpreted.
HTTP/1.1 keep-alive and prefork with SO_REUSEPORT are the usual
wins applied.
Two scenarios, both wrk -t8 -c256 -d10s on Linux 6.x / aarch64,
8 workers a side (Sinatra: 8 workers × 4 threads):
| Scenario | Server | Req/sec | p50 | p99 |
|---|---|---|---|---|
| hello (raw plumbing) | Tep | 167,150 | 40 µs | <1 ms |
| hello | Sinatra + Puma | 31,184 | 40 ms | 171 ms |
| api (SQLite + JSON) | Tep | 145,290 | 43 µs | 243 µs |
| api | Sinatra + Puma | 24,926 | 1.8 ms | 171 ms |
Numbers are conservative floors; a clean re-run on a quiet host is
expected to come in higher. Reproduce with bench/run_all.sh.
macOS note. Linux is Tep's primary deployment target. Builds and runs on macOS too, but Darwin's
SO_REUSEPORTdoesn't load- balance new connections across prefork workers — a single long- running response on the busy worker blocks every other request on the same listener. On Linux 3.9+ the kernel distributes accepts correctly, so prefork scales as the table above. The path forward for tep apps that need real concurrency on macOS isTep::Server::Scheduled(one worker, fibers per connection) + a cooperativeTep::Http— design + phases indocs/MACOS-CONCURRENCY.md.
What's in the box
Sinatra-shaped routing, filters, responses, templates, sessions, plus a collection of "batteries" — pure-Tep modules that cover the gem ecosystem's most common needs in a way that lowers cleanly through Spinel.
| Battery | What it covers |
|---|---|
Tep::SQLite |
libsqlite3 wrapper via a small C shim — exec / prepare / bind / step / col / first_str / first_int. |
Tep::Json |
encode primitives + flat-key decoder for JSON-over-HTTP. |
Tep::Logger |
levelled logger (debug/info/warn/error), stderr or file. |
Tep::Jwt |
HS256 JWT encode / verify / decode. |
Tep::Password |
PBKDF2-SHA256, 200k iters, self-describing storage. |
Tep::Security |
Cors (before-filter) + Headers (HSTS, nosniff, ...). |
Tep::Assets |
compile-time bundling for <app>/assets/*. |
Tep::Scheduler |
cooperative fiber scheduler with timer + I/O parking. |
Tep::Shell |
popen-based shell-out + small-file reader (/proc, /sys, /etc). |
Tep::Http |
Faraday-shaped outbound HTTP/1.0 client. |
Tep::Llm |
ruby-openai-shaped chat-completions client; backends interchangeable via base_url (Ollama / OpenAI / toy). Sync chat() + SSE chat_stream(). |
Tep::Llm::OpenAI::Server |
The other side of the OpenAI fence — serve OpenAI-compatible HTTP from local compute. Subclass Tep::Llm::OpenAI::Backend, wire Server.use(MyBackend.new) + Server.serve!(events_jsonl), get /v1/models + /v1/completions (non-streaming + SSE with "stream": true) + toy/v1 events out of the box. /v1/embeddings lives app-side (the lib stays float-free); see docs/OPENAI-SERVER-BATTERY.md. |
Tep::Proxy |
Sinatra-flavored reverse proxy. Subclass Tep::Proxy, override rewrite_path / before_forward / after_forward (non-streaming), or stream_request? / on_stream_chunk / on_stream_end (SSE / chunked pass-through). Block-form DSL — `proxy.before_forward do \ |
Tep::Events |
toy/v1 JSONL emitter (run_start / inference / run_end). Float-free by design — caller hands in integer wall_us, schema-side floats live in caller-built extra_json. The OpenAI-server battery wires it automatically; standalone for any per-request telemetry stream. |
Tep::WebSocket |
RFC 6455 server-side WebSocket. `websocket '/chat' do \ |
Tep::Parallel |
grosser/parallel-shaped fork fan-out. |
Tep::Job |
sidekiq-shaped queue over SQLite. |
PG |
ruby-pg-shape libpq client: PG::Connection, PG::Result, PG::Error; surface mirrors the pg gem (exec / exec_params / escape_* / fields / values / getvalue / sql_state). Designed so an eventual ActiveRecord-on-spinel port reuses the existing AR adapter with minimal divergence — see docs/PG-BATTERY.md. |
Tep::Auth |
Principal+delegate identity (Tep::Identity / Tep::AgentDelegation) + provider chain. Three providers shipped: Tep::AuthBearerToken (JWT-HS256), Tep::AuthSessionCookie (signed cookie), Tep::AuthOAuth2 (authorization-code grant issuance for bots/agents). Same req.identity surface regardless of provider; agents are first-class (identity.agent?, identity.acting_via.agent_id, capability subsetting). |
Tep::Broadcast |
In-process pub-sub + cross-worker via PG LISTEN/NOTIFY. Subscribe an fd to a topic (subscribe raw, subscribe_ws WS-frame-wrapped); publish writes to every matching subscriber. The seam Presence and LiveView build on. |
Tep::Presence |
Topic-keyed who's-here registry, agent-aware. Tep::Presence.track(req, topic, fd) records a (principal, session, topic) tuple with a 3-state structured status (`:available |
Tep::LiveView |
Phoenix.LiveView-shape server-rendered stateful UI over WebSocket. Subclass Tep::LiveView, override render + handle_event + (optionally) handle_presence_diff; broadcast_render fans the new HTML out to every subscribed viewer. Bootstrap client (~10 lines of inline JS) ships in Tep::LiveView.render_page. |
Tep::MCP |
Tool catalog for the agent-as-driver role. mcp_tool 'name', "desc" do; param :foo, Type, "..."; on_call do; ...; end; end registers a tool both at POST /tools/<name> (HTTP-direct) and through a JSON-RPC 2.0 dispatcher at POST /mcp (MCP-native — Claude Code / OpenCode / Gravity CLI). GET /llms.txt auto-publishes the catalog. See docs/MCP-BATTERY.md. Chunk 5.1; mcp_resource + streaming + OpenAPI in 5.2–5.4. |
Per-battery API docs and cookbooks live on the wiki. The full Sinatra-compatibility matrix is in SINATRA_COMPAT.md.
The last four batteries (Tep::Auth, Tep::Broadcast,
Tep::Presence, Tep::LiveView) ship a small framework for
"web apps in a live agentic age" — req.identity is always a
principal+delegate pair so agents acting on behalf of humans
are first-class through every battery. The end-to-end design
- a realistic chat-room scenario walked through every seam
lives in
docs/BATTERIES-DESIGN.md.
430 tests pass make test (serial, ~13 min on the gx10) or make test-parallel (subprocess fan-out, ~4 min — see test/run_parallel.rb). End-to-end demos that build and run:
examples/counter/— the smallestTep.livedemo. Shared integer counter; click in one tab, every other tab updates in <100ms. ~80 lines of Ruby + CSS, no JS to write (the bootstrap shell wiresdata-eventclicks through the WS).examples/experiments/— theTep::MCPbattery demo. Mock training-run manager driven by an MCP client (Claude Code / OpenCode / Gravity): 4 tools, 2 resources, capability gating, auto-published/llms.txt+/openapi.json+/mcpJSON-RPC. ~200 lines of Ruby plus anAGENTS.mdworked example.examples/agentic_chat/— the four-battery agentic demo. Sub-second WS push, multi-user chat, agent-spawn with OAuth2-style delegation. ~270 lines.examples/chatbot/— minimalistic OpenWebUI-style client backed by any OpenAI-compatible endpoint (Ollama / OpenAI / toy'stoy serve) exercising the full pre-agentic battery surface (Tep::Server::Scheduled+Tep::Llm+Tep::SQLite+Tep::Streamer+Tep::Session+Tep::Password+Tep::Jwt+Tep::Security::{Cors,Headers}+Tep::Assets+Tep::Json+Tep::Job+Tep::Logger) in ~1500 lines of Ruby + HTML + CSS + JS.examples/llm_gateway/— theTep::Proxystreaming demo. Block-form DSL gateway in front of an OpenAI-compatible upstream; pumps SSE token deltas straight through to the client and emits oneTep::Eventsinferenceevent per completed stream.examples/api_gateway/— the buffered-proxy demo. Capability-gated forwarding withbefore_forwardafter_forwardobservability hooks; same DSL, non-streaming.
examples/websocket_echo.rb—Tep::WebSocketin isolation;test/test_websocket_echo.rbperforms a real RFC 6455 handshake over a raw socket and round-trips a masked TEXT frame.
Type signatures (RBS)
sig/ ships RBS signatures for tep's
public surface, mirroring lib/tep/. They're for IDE tooling today
(Solargraph, RubyMine) and for forward compatibility with
spinel-side RBS consumption (discussion at #6) —
the goal is to let library authors carry the type-correctness burden
in .rbs files so app developers can write idiomatic Ruby without
the inference-warming seed dance that currently lives at the top
of lib/tep.rb. rake rbs:validate syntax-checks the tree.
Spinel-direct
tep build accepts either the Sinatra DSL or a lower-level
Tep-class style without the translator:
require_relative '../lib/tep'
class Hi < Tep::Handler
def handle(req, res)
"<p>hi, " + req.params["name"] + "!</p>"
end
end
Tep.get "/hi/:name", Hi.new
Tep.run!(4567, 1, false)
Useful for tracing where the translator's textual rewrites go.
Reporting bugs
Tep deliberately exists to find Spinel's edges. If you hit a Sinatra idiom that doesn't translate, a Spinel-emitted miscompile, a runtime hang — please file an issue with a minimal reproduction. "Your app doesn't build" is a useful data point.
License
MIT, see LICENSE.