Laravel ergonomics,
compiled to a binary
.
A Rust + Svelte 5 framework for hyper-responsive desktop apps. A container, providers, middleware and a typed bridge — Laravel's feel — but native, binary, and without runtime overhead. The CLI is Ratatosk (rata).
One command,
a running app.
With Rust 1.94+ and Node installed, rata new scaffolds a Rust bin crate and a Svelte 5 frontend wired together over the typed bridge. Elyra is distributed as a tagged GitHub release rather than through crates.io or npm, so a new project depends on the tag — and rata new --elyra <path> points it at a local checkout when you are working on the framework itself.
# Install the Ratatosk CLI (from the repo — not yet on crates.io) $ cargo install --git https://github.com/kwhorne/elyra-framework ratatosk # Scaffold and run a desktop app $ rata new myapp $ cd myapp && rata dev ✓ Rust core + Svelte 5 UI, hot-reloading # Generate the typed TS bridge from your commands $ rata codegen
A real framework,
not a wrapper.
A dependency-injection container, a middleware pipeline, an event bus, a typed IPC bridge, an Active Record ORM, plus windows, tray and an updater — all in the Rust core, with a Svelte 5 crown.
Binary IPC
MessagePack over an elyra://localhost custom protocol — no JSON in the hot path, no per-call serialization tax.
Commands + middleware
#[command] async fn is dispatched through a middleware pipeline, resolved from a container with providers — the Laravel request lifecycle, in Rust. A command can require an ability, #[command(can = "...")], and a refusal names which gate said no; #[command] also rejects an attribute it cannot parse rather than ignoring it.
Live queries
A #[command(live)] is a command the frontend subscribes to. Elyra records the tables it reads, re-runs it when a write changes one, in that window, another one or a background job, and pushes the new result to every window watching, coalesced over one frame, with nothing pushed when the result is unchanged. On the frontend it is one store, live.customers_index(query), typed by codegen, whose value is { value, error, loading }, and a failed re-run keeps the last value. A re-run that writes fails, a window holds at most 64 subscriptions, and db.touch(key) or ctx.invalidate(key) cover what is not a table, such as settings:theme. Since 0.9.0.
Every app is an MCP server
App::mcp(Mcp::new().allow_abilities([..])) chooses which commands an AI agent may call: those whose can ability it grants, in a grant separate from the frontend's, and a command without an ability never is. Nothing is exposed until the app calls it. The commands become tools for Claude, Cursor or VS Code, the doc comment is the description, and JSON Schemas (2020-12) for the arguments and the result are built from the types after serde's renames, tagging and flattening. A call runs through the same middleware and validation as one from the frontend, and a validation bag, a command error, a panic or a type mismatch comes back as a tool error the model can act on. The client launches myapp --mcp: when the app is open the agent works inside it, with the same database, live queries and events, and the windows update as it goes; when it is not, the app serves MCP without a window. The open app answers on a private 0600 Unix socket, after a challenge-response on a per-install token that never crosses the wire, so a process that takes the socket path first learns nothing and the shim runs headless instead. rata mcp install prints the config for Claude, Cursor or VS Code and edits nothing. Since 0.10.0, with one small break: CommandRequest has a new field, origin, so code that builds one by hand sets origin: Origin::Frontend.
The agent asks before it deletes, and can be watched
A tool whose ability Mcp::confirm names runs only once the user says yes, in the client: the question names the client and the tool and gives the first line of the command's doc comment and the arguments. The state of the question is signed with a key that lives only in the process, bound to the tool, the arguments and the client, valid for five minutes, and it runs the call at most once, so an accepted delete cannot be replayed. A client that cannot be asked gets a tool error and the tool does not run, and so does a decline or no answer within ten minutes. Tools are rate limited per tool, across connections, at 60 calls a minute unless Mcp::rate_limit says otherwise, and a throttled call is refused before the user is asked to confirm it. Every call dispatches AgentCalled { tool, client, ok, millis } for an audit log, without the arguments, which may be personal data, and the elyra:mcp channel tells every window, so onAgent(handler) in @elyra/runtime can drive a Claude is adding a customer indicator.
Live commands, as things an agent can watch
A granted #[command(live)] that can run with no arguments given is an MCP resource, app://<command>: every argument an Option, or a struct whose fields all are, and not one that needs confirmation. resources/list lists them, resources/read runs one as the agent, and a subscription gets notifications/resources/updated whenever the live registry would push a new result to a window: coalesced, only when it changed, only for what it read. Through the shim one agent sees another's writes, and the window's, because they share the running app's live registry; two headless processes do not. Since 0.10.0. And since 0.11.0 a live command that takes arguments is a resource template the agent fills in: customers_show(id) becomes app://customers_show/{id}, and customers_index(query) app://customers_index{?direction,page,per_page,search,sort}. The agent reads app://customers_show/12 and subscribes to it, and is told only when what its arguments select changes. A value that does not convert, a parameter the template does not name or a repeated one is answered Invalid arguments, with what is wrong per variable, and templates add no reach: the agent could already call each command as a tool with any arguments; a template lets it watch the result.
Event bus
EventBus + channel() push from Rust to the frontend, batched per flush over a long-poll, so the UI thread never blocks. Since 0.6.0 there are domain events on the Rust side as well — App::listen for a typed event — and App::broadcast forwards one to the frontend on a channel, so the same event can drive a listener and a view.
Typed bridge
rata codegen turns specta types into TypeScript plus a typed api.* facade — call your Rust commands with full type safety. Since 0.8.0 serde_json::Value crosses it as a JSON type rather than an error, translation keys can be generated as types so a misspelt key, or one missing a placeholder it needs, is a compile error instead of rendering its own name, and @elyra/runtime has a small hash router (#/customers/12) for the screens make:resource writes.
Active Record ORM
One Database over SQLite/MySQL/Postgres, rata migrate migrations, and #[derive(Model)] with a query builder, relations and auto-hydration — plus joins, where_between / where_null / or_where_eq, count / sum / avg / exists, paginate, chunk, bulk update and delete, soft deletes, transactions, and a Schema builder that renders DDL per driver so migrations can be written in Rust. Since 0.6.0: belongs_to_many through a pivot, casts, local and global scopes, and model factories for tests. And two things this card listed before they fully held: find and all returned soft-deleted rows until 0.6.0, and count, sum and avg dropped a query's joins. Both are fixed, and #[derive(Model)] now rejects an option it does not know instead of discarding it. A third, fixed in 0.9.1: a cast = "json" field on a t.json() column, which is JSONB on Postgres, could not be written (column is of type jsonb but expression is of type text) or read (the Any driver rejected the whole row), and the docs told you to use t.text() there. On Postgres the cast now converts in SQL both ways, so t.json() works on every driver; a TEXT column keeps its JSON exactly as written, a JSONB one normalizes it (key order, whitespace) and reads back as the same value, and make:resource --generate now makes a json field with t.json. Nothing breaks on upgrade, and nothing moves an existing column to JSONB for you: that is a migration you write. A fourth, fixed in 0.10.1: rata migrate answered Nothing to migrate and created no tables for the Rust migrations that make:resource and make:migration --rust write, because they are compiled into the app. It now runs the app in its migrate mode when src/ has one, which applies both kinds in one batch. A rollback used to drop the records of the other kind without running their down, leaving the tables in place and the next migrate failing on them; every migration in the batch now runs its own down, and a batch holding one that is neither a file nor registered is not rolled back. ELYRA_MIGRATE=up applies the pending SQL files too and prints what it did, and elyra_db::Error has a new variant, UnknownMigration, which an exhaustive match needs an arm for.
Queue, Cache & Storage facades
An in-process background job Queue, a TTL-aware Cache, and a filesystem Storage disk — each with the same ergonomic surface as its Laravel counterpart. Plus a cache-backed RateLimiter (too_many_attempts / hit / attempt, self-expiring per key) and a Scheduler for recurring work — every / hourly / daily with async closures, bound through a provider. Since 0.6.0 the Queue can be durable, backed by the app's database, so a job queued before a crash is still there after it; in memory it is not, and the docs say so.
The bridge is gated, not open
The IPC surface used to answer any origin: Access-Control-Allow-Origin: * went out on every response, including the routes for commands, the filesystem, the clipboard and process spawning. Production builds now send no CORS headers at all, every /__* request must carry a per-run token, and each route maps to a capability — with StoreClear, CacheFlush, StorageDelete and UpdaterInstall opt-in rather than granted. Sidecar spawn from the frontend is deny-by-default, shell.open refuses file: URLs and anything executable, a strict CSP is served by default, and request bodies are size- and depth-bounded so a 10 KB payload cannot overflow the stack.
Testable without a window
TestApp invokes commands through the real middleware pipeline with no window at all — invoke_ok, invoke_err, invoke_validation_errors, assert_emitted — and TestShell drives the actual /__* routing when you want to test the bridge itself rather than around it. Desktop code that can only be verified by clicking is how desktop code rots. Since 0.6.0 Queue::fake(), Storage::fake() and Dispatcher::fake() record instead of act, and App::swap replaces a binding after every provider has run, so a test can put a fake mailer behind the real wiring.
Log, Config & Secrets
A Log facade with levels, targets, a rotating file sink and log_path() for a “send us your log” button — every command dispatch is traced with its duration. Config merges elyra.toml, config/*.toml, .env and process env into one dotted-key map with ${VAR} expansion. And Secrets keeps tokens in the OS keychain — macOS Keychain, Windows Credential Manager, Linux Secret Service — instead of the clear-text settings file, with get_or_migrate_env to move off environment variables. Since 0.5.8 a read returns a Secret rather than a String: it derefs to &str, so it is used the same way, but its Debug prints Secret(***), so a token that strays into a log line does not arrive in it.
Validation, all the way to the form
Check command input against a rule string — “required|email|min:18” — and get a per-field error bag back. Return it with ? from a command and the frontend reads the structured errors through validationErrors(err) in @elyra/runtime, so a field error stays a field error across the IPC boundary instead of collapsing into one message. Since 0.7.0 fields can be paths — address.city, items.*.name, with errors keyed items.1.name — fourteen rules became forty-four, and unique and exists check the database through the async validator. An unknown rule now panics, naming the rule and the field: a misspelt required used to be ignored, which quietly let invalid input through. Since 0.8.0 the messages can be translated from the same files as the rest of the app. And since 0.10.2, when the database cannot be asked (a locked database, a dropped connection, a missing table), unique and exists fail the field with The email could not be checked right now rather than panicking, and the input is not let through; a count that could not be read used to pass unique silently.
One set of translations, both halves
Since 0.7.0 one JSON file per locale serves both sides of the app: __() and trans_choice() in Rust, $t and $tc in Svelte, with placeholders and plurals the way Laravel does them. A string the backend puts in an error and the same string on a button come from the same file, so they cannot drift into two translations of one sentence.
Artisan-style generators
rata make:command, make:provider and make:model scaffold a file under src/ with names normalised the way you would expect — BlogPost becomes blog_post.rs with a BlogPost struct and a blog_posts table. It prints the mod line and registration rather than editing main.rs for you, and it never overwrites an existing file: generated code is a starting point, not something that owns your wiring. Since 0.8.0 rata make:resource Customer writes the vertical slice a desktop app needs for each kind of record — list, search, paging, a validated form, a detail view, delete with a confirmation, and the Rust commands, events and tests behind them — in three tiers: for a model you wrote, with --view for the Svelte screens, and with --generate name:string email:email:unique for the model, migration, factory and seeder too. The safety rails are in the generated code, not around it: the sort column goes through an allowlist, per_page is capped at 100, and every command needs an ability the app has not granted yet. Everything is checked before anything is written, it prints the Cargo.toml lines it needs rather than editing Cargo.toml, and an existing resource is only replaced with --force. Only the two registries are rata's, and it refuses to overwrite one whose first line says it isn't. Since 0.9.0 the generated list and detail views read from live.*, so a customer added, edited or deleted in another window shows up without a reload, while the form still reads once so it never overwrites what is being typed. A resource made with 0.8 keeps its non-live views until you regenerate it, and regenerating a --generate resource without --generate is refused, with the command to use instead.
AI SDK
A Laravel-inspired elyra-ai crate: text, chat, tools and structured output — plus retries with backoff, provider failover, response caching, rate limiting + token budgets, native web search/fetch, and text-to-speech + transcription.
Windows, menus & tray
Multi-window management with window-state persistence, a native application menu, global shortcuts and a system tray — each wired from a single builder call (tao + wry).
Signed auto-updater
An ed25519-verified updater with a built-in auto-update toast — ship updates safely with one builder call.
Sidecar processes
Spawn and supervise child processes (a Node server, a Python worker) from the app, with autostart-at-login so it comes up with the user's session.
Settings store
A persistent key-value store (store in @elyra/runtime) for app preferences — typed, synced to disk, no boilerplate.
System integration
Native desktop essentials — window controls, file drop, notifications, single-instance and deep-linking — exposed through @elyra/runtime.
Built-in UI components
Themed, dependency-free primitives plus a wired About dialog and update toast — consistent, native-feeling chrome for free.
Compiled, no overhead
A single binary with no embedded runtime — fast startup, low memory, and Rust's safety across the whole app.
Native desktop apps,
the way you build for the web.
Free and open source. Scaffold an app with rata new and read the docs to go deeper.