CyberTrainTutorial

Getting started: the blog

Mirrors Rails' own Getting Started: a blog with articles and comments. You will scaffold articles, add validations, nest comments under them, write a test that drives the app in-process, and build the app into a deployable dist/ folder. The finished app is examples/blog, exercised by CI on Linux and macOS.

You need
Ruby 3.2+, cc, make, git, curl, SQLite 3 headers (Spinel 2026.09.12 is installed for you) Step 00
You start from
gem install cybertrain
You finish with
dist/blog

You are here:Setup Ruby, cc, SQLite

Requirements

WhyCyberTrain compiles through Spinel to C, so it needs a C toolchain and the SQLite headers; the cybertrain command is a gem, so it needs Ruby.

  • Ruby 3.2+ and RubyGems, for the cybertrain command only; applications never run on CRuby.
  • Spinel 2026.09.12, untested against other versions. You do not install it by hand: the first command that needs it builds that release into ~/.cybertrain (a few minutes, once), unless a spinel of that release is already on PATH.
  • A C toolchain (cc, or the compiler CC names) and make, plus git and curl, to build Spinel, which compiles every program to C.
  • SQLite 3 headers and library: libsqlite3-dev on Debian/Ubuntu; present with Xcode's command line tools on macOS.
  • OpenSSL headers (libssl-dev) are optional: without them Spinel builds without its openssl package, which CyberTrain does not use.

cybertrain doctor checks these tools and headers and the Spinel install; when a required one is missing it also prints the install command for your platform.

How CyberTrain installs SpinelWhat happens the first time a command needs spin

cybertrain new, db, server, build and spin first look for Spinel 2026.09.12: a spinel and spin of that release already on PATH, else the copy under ~/.cybertrain/spinel/2026.09.12/, else they build that copy from the Spinel release tag (git clone, make deps, make -j, make install), once, in a few minutes. The log is ~/.cybertrain/log/spinel-2026.09.12-build.log; if a tool the build needs is missing, cybertrain says which and prints the install command for your platform before it starts.

cybertrain setup does the same on request (--force rebuilds the copy under ~/.cybertrain) and, when its own copy is the one in use, prints the line that puts it on your PATH, for running spin yourself:

terminal — any directoryshell
export PATH="$HOME/.cybertrain/bin:$PATH"

~/.cybertrain/bin holds spinel and spin links to the release in use. Without it on PATH, cybertrain spin ARGS... runs spin with that release from any directory. CYBERTRAIN_HOME replaces ~/.cybertrain, and CYBERTRAIN_SPINEL_HOME=PREFIX uses the Spinel installed in PREFIX instead (it must be that release). A spinel of another release on PATH is never used or touched.

No machine to install on? The playground opens this blog in a GitHub Codespace with steps 02, 03, 04 and 07 already done.

You are here:Setup the cybertrain CLI

Install the CLI

Whycybertrain is the command that creates apps, scaffolds resources, migrates, serves and builds.

The first line installs it from RubyGems. The rest is the whole tutorial at a glance; the steps below take it one command at a time.

terminal — any directory, then blog/shell
gem install cybertrain
cybertrain new blog          # installs Spinel 2026.09.12 the first time (a few minutes)
cd blog
cybertrain g scaffold article title:string body:text
cybertrain db migrate
cybertrain server            # http://127.0.0.1:3000
cybertrain build             # production: dist/blog, dist/public/, dist/storage/, dist/tmp/

The cybertrain command is plain Ruby and the gem runs it under CRuby (3.2 or newer); neither the framework nor Spinel is in the gem. Its commands (cybertrain help prints them):

  • cybertrain new NAME [--path DIR | --version V | --git URL [--ref R]] [--skip-spin] creates an application in NAME, then runs spin lock and spin run gen in it, installing Spinel first when it is needed.
  • cybertrain generate scaffold NAME field:type ... (alias g scaffold) adds a resource to the application in the current directory.
  • cybertrain db migrate generates, applies pending migrations, and generates again (spin run gen; spin run db -- migrate; spin run gen); cybertrain migration is kept as an alias.
  • cybertrain db COMMAND... runs any other database command after spin run gen: status, rollback [N] (which generates again afterwards), schema:dump, create.
  • cybertrain server [PORT] generates, then starts the development server (spin run gen; spin run NAME, or spin run NAME -- PORT; default port 3000).
  • cybertrain build builds NAME with app/views/ embedded and assembles dist/ (the binary, public/, storage/, tmp/).
  • cybertrain spin ARGS... runs spin with the pinned Spinel, from any directory: cybertrain spin test, cybertrain spin run gen -- --check.
  • cybertrain setup [--force] installs Spinel 2026.09.12 into ~/.cybertrain unless a spinel of that release is already on PATH; --force rebuilds the copy under ~/.cybertrain.
  • cybertrain doctor checks the C toolchain, the SQLite headers and the Spinel install.
  • cybertrain version prints the version (cybertrain 0.2.1); cybertrain help prints the usage.

db, server and build run inside an application; NAME is the [package] name in its spin.toml.

You are here:You write blog/

Create the application

WhyYou get the Rails directory conventions plus three bin/ programs: bin/blog.rb (the app), bin/gen.rb (the generator) and bin/db.rb (development migrations).

terminal — any directoryshell
cybertrain new blog
cd blog

cybertrain new writes the application with its spin.toml pointing at the release matching the CLI:

spin.tomltoml
[package]
name = "blog"
version = "0.1.0"

[dependencies]
cybertrain = { git = "https://github.com/saeki-mototsune/cybertrain", ref = "v0.2.1" }

Then it makes sure Spinel is installed (the first time, that means building Spinel 2026.09.12 into ~/.cybertrain: a few minutes, once; see step 00) and runs spin lock (spin fetches the framework into its cache, ~/.cache/spin/packages/, and pins the commit in spin.lock) and spin run gen, much as rails new runs bundle install, so cybertrain server works right away. It ends by printing the next steps. Nothing of the framework is copied into the app; commit spin.toml and spin.lock.

If Spinel or one of the two spin steps fails, the files stay, new exits with 1 and prints what to run once the cause is fixed (for Spinel: cybertrain setup, then cybertrain spin lock && cybertrain spin run gen in the new directory). --skip-spin only writes the files; --path DIR depends on a local checkout instead, --git URL [--ref R] on the framework at URL (at branch or tag R), and --version V on an index version once cybertrain is on a spin-index.

App names use lowercase letters, digits and _, starting with a letter. The generated tree:

blog/text
blog/
  spin.toml  config/app.rb  config/routes.rb
  db/schema.rb  db/migrate/
  app/controllers/application_controller.rb  app/models/  app/views/  app/helpers/
  public/  bin/blog.rb  bin/gen.rb  bin/db.rb
  gen/  storage/  tmp/  test/

bin/blog.rb is the application's one entry point, the program spin build blog compiles: ./blog serves, ./blog migrate and ./blog db status manage the database. bin/gen.rb runs the generator (spin run gen), and bin/db.rb runs the migrator during development (cybertrain db migrate uses it, since bin/blog.rb cannot compile until the first migration has generated gen/models/). gen/ already holds views.rb, the committed, empty table of embedded views.

ApplicationController starts like this, so a missing record maps to a 404 through rescue_from:

app/controllers/application_controller.rbruby
class ApplicationController < Cybertrain::Controller
  rescue_from Cybertrain::RecordNotFound, with: :record_not_found

  private

  def record_not_found
    render plain: "Not Found", status: :not_found
  end
end

You are here:You write app/, db/migrate/

Scaffold an article

WhyOne command writes the whole CRUD slice, Rails-style.

terminal — blog/shell
cybertrain generate scaffold article title:string body:text

It writes a migration, a model, a controller and five views (index, show, new, edit, _form), and inserts resources :articles into config/routes.rb. Field types are string, text, integer, float, boolean, date, datetime and references; the default is string. The quick start's cybertrain g scaffold is the same command.

Existing files are never overwritten: each file is reported as create, identical or exist.

You are here:You write config/routes.rb

Point the root route

Why/ should show the article list.

config/routes.rbruby
# config/routes.rb
Cybertrain::Routes.draw do
  root "articles#index"
  resources :articles
end

Routes are read at build time: spin run gen turns them into gen/routes.rb. cybertrain server, cybertrain db migrate and cybertrain build run it for you.

You are here:You write app/models/

Add model validations

WhyAs in Rails' guide, add a length validation by hand.

app/models/article.rbruby
# app/models/article.rb -- only the first string field gets a default
# presence validation; add anything past that yourself.
class Article
  validates :title, presence: true
  validates :body, presence: true, length: { minimum: 10 }
end

class Article has no superclass written: the Cybertrain::Model superclass, columns and finders come from gen/models/article.rb. A failed save reports messages such as "Title can't be blank" and "Body is too short (minimum is 10 characters)".

You are here:You write app/controllers/

The ArticlesController

WhyFull CRUD, Rails-shaped down to strong params and flash-then-redirect, except for two things Spinel forces everywhere.

app/controllers/articles_controller.rbruby
# app/controllers/articles_controller.rb (index/show/edit are as plain as
# Rails' own scaffold; update mirrors create)
class ArticlesController < ApplicationController
  before_action :set_article, only: [:show, :edit, :update, :destroy]

  def new_action   # a `new` method would shadow ArticlesController.new
    @article = Article.new
  end

  def create
    @article = Article.new(article_params)
    if @article.save
      flash[:notice] = "Article was successfully created."
      redirect_to article_path(@article), status: :see_other
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def set_article
    @article = Article.find(params[:id])
  end

  def article_params
    params.require(:article).permit(:title, :body)
  end
end

The scaffold writes the full controller with index, show, edit, update and destroy too; the excerpt above omits them. In index, @articles = Article.all.to_a: the .to_a is required.

Instance variables are found by a lexical scan of @ivar =, so re-run cybertrain spin run gen after adding one; the development server does this for you.

You are here:Build time, then run time gen/ → build/bin/blog

Generate, migrate, run

WhyTurn schema and routes into gen/, create the SQLite database, start the server.

terminal — blog/shell
cybertrain db migrate    # spin run gen; spin run db -- migrate; spin run gen
cybertrain server        # http://127.0.0.1:3000; rebuilds on Ruby edits, views reload without a rebuild

spin run gen reads db/schema.rb and config/routes.rb, scans app/ and writes gen/; spin run db -- migrate applies db/migrate/*.rb and rewrites db/schema.rb. The order matters: the first spin run gen puts the new migration into gen/migrations.rb, which bin/db.rb requires, and the second builds the models from the rewritten db/schema.rb.

cybertrain server prints each spin command it runs, then the app's boot banner. The first start compiles the app, so it takes a while:

output — cybertrain servertext
=> Booting cybertrain 0.2.1
=> development environment (1 worker)
=> Watching app/, config/ and db/schema.rb for changes
* Listening on http://127.0.0.1:3000
Use Ctrl-C to stop
  • Re-run cybertrain spin run gen and commit gen/ after touching the schema, routes, or a controller's or model's callbacks and ivars. The development server does this for you.
  • Change the port with cybertrain server 4000 (or PORT=4000).
  • cybertrain db adds the -- that spin run db needs before its arguments; cybertrain db status and the other database commands work the same way.

You are here:You write db/migrate/, app/

Scaffold comments

WhyThe references type gives you the foreign key that the association is derived from.

terminal — blog/shell
cybertrain generate scaffold comment commenter:string body:text article:references
cybertrain db migrate    # the scaffold wrote a migration for the comments table

article:references adds an article_id column, index and foreign key — enough for Article/Comment to get their association; there's no has_many/belongs_to to write, spin run gen reads it off the foreign key. The resulting schema:

db/schema.rb (excerpt)ruby
  s.create_table "comments" do |t|
    t.string "commenter"
    t.text "body"
    t.integer "article_id", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

  s.add_index "comments", ["article_id"], name: "index_comments_on_article_id"

  s.add_foreign_key "comments", "articles", column: "article_id"

The scaffold inserts an independent resources :comments line, which the next step replaces.

You are here:You write config/routes.rb

Nest the routes

WhyRails' guide nests comments under articles.

config/routes.rbruby
Cybertrain::Routes.draw do
  root "articles#index"

  # A nested block takes the mapper explicitly (Spinel's instance_eval
  # trampoline only rewires `self` for the outermost block): `articles.resources`.
  resources :articles do |articles|
    articles.resources :comments, only: [:create, :destroy]
  end
end

You are here:You write app/controllers/

The CommentsController

WhyOnly two actions are reachable, and the parent article comes from the URL (params[:article_id]), not :id.

app/controllers/comments_controller.rbruby
class CommentsController < ApplicationController
  before_action :set_article

  def create
    @comment = Comment.new(comment_params)
    @comment.article_id = @article.id
    if @comment.save
      flash[:notice] = "Comment was successfully created."
    else
      flash[:alert] = "Comment could not be saved: #{@comment.errors.full_messages.join(", ")}"
    end
    redirect_to article_path(@article), status: :see_other
  end

  def destroy
    @comment = Comment.where(article_id: @article.id).find(params[:id])
    @comment.destroy
    flash[:notice] = "Comment was successfully destroyed."
    redirect_to article_path(@article), status: :see_other
  end

  private

  def set_article
    @article = Article.find(params[:article_id])
  end

  def comment_params
    params.require(:comment).permit(:commenter, :body)
  end
end

Then add validations to the comment model:

app/models/comment.rbruby
class Comment
  validates :commenter, presence: true
  validates :body, presence: true
end

Article#comments is an Array here, so Rails' @article.comments.find(params[:id]) becomes Comment.where(article_id: @article.id).find(params[:id]). A comment under the wrong article returns 404.

You are here:Run time app/views/, read from disk in development, embedded by cybertrain build

Views and partials

WhyThe article page lists its comments and ends with a form bound to [@article, @comment].

First, delete the now-unreachable index, show, new and edit comment views, and give ArticlesController#show a blank @comment:

app/controllers/articles_controller.rb (excerpt)ruby
  def show
    @comment = Comment.new
  end

Then render the comments and the form on the article page:

app/views/articles/show.html.erb · app/views/comments/_form.html.erberb
<%# app/views/articles/show.html.erb %>
<% @article.comments.each do |comment| %>
  <%= render "comments/comment", comment: comment %>
<% end %>
<%= render "comments/form" %>

<%# app/views/comments/_form.html.erb %>
<%= form_with(model: [@article, @comment]) do |f| %>
  <%= f.label :commenter %> <%= f.text_field :commenter %>
  <%= f.label :body %> <%= f.text_area :body %>
  <%= f.submit %>
<% end %>

The comment partial, as it ships in the example:

app/views/comments/_comment.html.erberb
<%# locals: (comment:) %>
<div class="comment">
  <p>
    <strong>Commenter:</strong>
    <%= comment.commenter %>
  </p>

  <p>
    <strong>Comment:</strong>
    <%= comment.body %>
  </p>

  <%= button_to "Destroy comment", [@article, comment], method: :delete %>
</div>

Both button_to ... [@article, comment] and form_with(model: [@article, @comment]) resolve through the same [parent, child] rule: a new child routes to the nested collection (article_comments_path), a persisted one to the nested member (article_comment_path) — generated into gen/routes.rb from the nested resources block above.

You are here:Build time cybertrain spin test

Test it

Whyminitest and RSpec find tests by reflection, which an ahead-of-time compiler cannot do, so CyberTrain ships Cybertrain::Test.

A model test

test/articles.rb (snippet)ruby
require_relative "support/blog_test"   # the app, BLOG and Cybertrain::Test

test "title must be present" do
  article = Article.new(body: "a body long enough to pass length")
  refute article.save
  assert_includes article.errors.full_messages, "Title can't be blank"
end

Assertions: assert, refute, assert_equal, assert_nil, assert_includes, assert_raises("Class") { }, flunk. The example also uses assert_response and assert_redirected_to.

A full HTTP flow

test/articles.rb (snippet)ruby
require_relative "support/blog_test"   # the app, BLOG and Cybertrain::Test

test "POST /articles creates an article and redirects to it" do
  client = Cybertrain::Test::Client.new(BLOG)
  client.get("/articles/new")
  res = client.post("/articles", { "authenticity_token" => BlogTest.form_token(client),
                                   "article[title]" => "Hello Rails",
                                   "article[body]" => "I am on Rails! This is my first article." })
  assert_redirected_to res, BlogTest.article_path(Article.last)
  assert_response res, :see_other
end

Cybertrain::Test::Client drives an app in-process (no socket) with a cookie jar, so sessions and CSRF behave as behind a browser. Requests without the token get a 403.

Run the tests

terminal — blog/shell
cybertrain spin test

cybertrain spin test runs spin test with the pinned Spinel; with the toolchain on PATH (step 00), spin test does the same. It compiles each test/*.rb into its own program and diffs its output (stdout and stderr together) against a committed test/<name>.rb.expected snapshot. Files in test/support/ are required, not run. To write a snapshot:

terminal — blog/shell
cybertrain spin test --regen test/<name>.rb
rm -f build/test/<name>; cybertrain spin test test/<name>.rb; [ -x build/test/<name> ] && ./build/test/<name> > test/<name>.rb.expected 2>&1

--regen writes the snapshot from CRuby's output. Tests that touch SQLite through FFI can't run under CRuby, so their snapshot comes from the compiled binary (the second line above). cybertrain spin test exits non-zero while a snapshot is stale, so it is followed by ;, not &&; the [ -x ] check skips the capture when the compile failed, so the old snapshot is kept. Tests that cannot run under CRuby need this snapshot before cybertrain spin test can pass; other tests without a snapshot are compared with CRuby's output.

You are here:Run time dist/blog

Build for deployment

WhyOne command builds the app into a deployable dist/ folder, with every view embedded.

terminal — blog/shell
cybertrain build

This writes dist/: the app binary with every view embedded, plus the files it uses beside it. It runs spin run gen -- --embed-views, spin build blog and spin run gen (which puts the committed, empty gen/views.rb back, even after a failure or Ctrl-C), then assembles:

  • dist/blogthe compiled framework, controllers, models and template interpreter, with every view embedded
  • dist/public/static assets and error pages, or let a reverse proxy serve it
  • dist/storage/created empty; the SQLite file by default (a rebuild never empties it)
  • dist/tmp/created empty (a rebuild never empties it)

Copy dist/ to a machine with the same OS and CPU (the binary links the system SQLite) and run it there. A built binary is production by default (CYBERTRAIN_ENV overrides). Set CYBERTRAIN_SECRET_KEY_BASE; for illustration:

terminal — production hostshell
cd dist
export CYBERTRAIN_SECRET_KEY_BASE="$(openssl rand -hex 32)"
./blog migrate && ./blog

./blog migrate applies the migrations compiled into the binary, and ./blog db status shows their status; PORT sets the port. Keep the secret the same across restarts: changing it ends every session.

Cybertrain::Config reads the environment first; config/app.rb overrides:

VariableAttributeDefault
CYBERTRAIN_ENVenv"development"; "production" in a binary from cybertrain build (also "test")
PORTport3000
CYBERTRAIN_DATABASEdatabase_pathstorage/#{env}.sqlite3
CYBERTRAIN_SECRET_KEY_BASEsecret_key_baserequired in production; dev/test auto-generate one into tmp/secret_key
SPINEL_WORKERSworkers1
CYBERTRAIN_HOSThost"127.0.0.1"; 0.0.0.0 listens on every interface (containers)
CYBERTRAIN_SESSION_SAME_SITEsession_same_site"Lax"; also Strict or None (None always adds Secure); anything else stops the server at boot
CYBERTRAIN_SESSION_PARTITIONEDsession_partitionedfalse; 1 or true adds Partitioned (and Secure)
config/app.rbruby
# config/app.rb
Cybertrain.configure do |c|
  c.port = 3000
  c.workers = 1
end
  • The server speaks plain HTTP/1.1: put nginx or Caddy in front for TLS and HTTP/2. The session cookie is Secure in production, so serve it over HTTPS (set c.session_secure = false in config/app.rb only if production really is plain http).
  • Production refuses to start without CYBERTRAIN_SECRET_KEY_BASE.
  • In production an exception answers 500, and a bare error response (the router's plain-text 404, head :not_found, render plain:) is replaced by public/<status>.html when that file exists; cybertrain new writes 404.html and 500.html.
  • SIGTERM stops accepting, lets requests in flight finish and exits when they are done, or after the server's drain_timeout (10 s).
  • The watcher and rebuild loop never run in production. A running cybertrain server skips its rebuilds while cybertrain build holds tmp/cybertrain-build.lock.

Things to know

WhyLimits and quirks worth knowing before they surprise you.

  • Sessions hold Strings only, in an HMAC-signed cookie.
  • SQLite only, through Spinel’s FFI.
  • No backtraces: errors show class, message, request line and template name/line.
  • A misspelled attribute in a view renders blank instead of raising; this is a known gap.
  • Associations come from schema foreign keys only; no has_many :through, includes, pluck, dependent:, enums, STI or polymorphic associations.
  • No namespace, respond_to/formats, constraints or mount; render json: is minimal.
  • Only the first string field gets a default presence validation in a scaffolded model.
  • The generator uses a lexical scan (regexes), not a parser, over app/controllers and app/models.
  • Views are embedded at build time: a view-only change needs cybertrain build and a restart before it reaches production.
  • Build dist/ on the same OS and CPU as the machine that runs it: the binary links the system SQLite.
  • SPINEL_WORKERS defaults to 1.
  • Only Spinel 2026.09.12 is tested.

Differences from Rails

WhyThe full table from the README, in one place.

Differences between Rails and CyberTrain (full table from the README)
RailsCyberTrain
rails consoleNo console — Spinel has no eval
Edit code, the running app picks it upRuby needs a rebuild; cybertrain server in development does this for you (rebuild, stop, execv)
Edit a view, no reload neededSame — views are parsed from disk per request in development
def newdef new_action (new would shadow Klass.new(ctx))
before_action { do_thing } (implicit self)before_action { |c| c.do_thing } — no instance_exec on a stored block, so callbacks take the controller/record explicitly
resources :posts do resources :comments endresources :posts do |posts| posts.resources :comments end — nested blocks take the mapper explicitly
ERB compiles to a method; any object, any methodInterpreted against a fixed grammar/dispatch table
Session holds any marshalled objectSession values are Strings only, HMAC-signed cookie
Many database adaptersSQLite only, via FFI
has_many :through, includes, pluck, dependent:, enums, STI, polymorphic associationsNot implemented; associations come from schema foreign keys only
namespace, format/respond_to, constraints, mountNot implemented — flat names, render json: only
Full backtrace on an exceptionClass, message, request line, template name/line — Spinel exposes no backtraces
Rack, its middleware, and any gem in a GemfileNo Rack compatibility; a small fixed middleware set; Spinel's own spin-index, limited to what compiles under its Ruby subset
minitest / RSpecCybertrain::Test — reflection-based runners can't work ahead-of-time
Auth, mailers, jobs, ActionCable, asset pipeline, i18nNot built (outside the MVP)

Not exhaustive.

Where next