Imported from onelxj/plugin_test (
skills/chat-app/SKILL.md). Install upstream withnpx skills add onelxj/plugin_test --skill chat-app. Copyright stays with the author.
/chat-app — Build Reboot AI Chat Apps
Build complete Reboot AI Chat Apps from a user description.
Installation
From GitHub
Add the Reboot skills marketplace and install the plugin:
# 1. Add the marketplace (one-time).
claude plugin marketplace add reboot-dev/reboot-plugin
# 2. Install the plugin.
claude plugin install reboot@reboot-plugin
If you install the plugin within claude with /plugin you need to restart for
the configuration and skill to load correctly.
Or add to your project's .claude/settings.json so teammates
are automatically offered the plugin on first use:
{
"extraKnownMarketplaces": {
"reboot-plugin": {
"source": {
"source": "github",
"repo": "reboot-dev/reboot-plugin"
}
}
},
"enabledPlugins": {
"reboot@reboot-plugin": true
}
}
Local (repo checked out)
If you have the reboot-plugin repo cloned locally:
claude --plugin-dir /path/to/reboot-plugin
When to Use
- Building a new Reboot AI Chat App from a description
- Adding features, state, or UI to an existing Reboot AI Chat App
- Modifying state model, methods, or React UI in a Reboot AI Chat App
Workflow: Plan First, Then Build
Always enter plan mode before writing code. The state model is the foundation — getting entities, field types, or method types wrong means regenerating everything across 12+ files.
Plan Phase
- Analyze the user's description using the State Model Assessment below
- Enter plan mode (
EnterPlanMode) - Present the proposed design:
- User type and its methods (as the MCP front door to the application types discussed below, creating new ones and locating existing ones)
- Application types: state shape (fields, types, tags)
- Method map: which operations, which method type (Reader/ Writer/Transaction/Workflow), which get UI()
- Tool surface: what the AI will see as callable tools
- Get user approval before writing any files
- Then execute the Step-by-Step Build Flow
For updates to existing apps, still plan: read current state, propose changes, confirm, then modify.
State Model Assessment
Before writing code, analyze the user's request:
- Application types: What primary things is the user managing?
(counter, inventory, chat thread, etc.) Each becomes
its own
Typewith its own state. - User methods: How does the AI create instances of
application types? Each gets a
TransactiononUserthat calls<Type>.create(context). - State shape: Fields, types — lists, nested objects,
primitives. Each gets
Field(tag=N). Nested Model subobjects (preferences, profile, config — owned 1:1 by a parent state) must beOptional[X] = Field(tag=N, default=None)and hydrated in the parent's factorycreateWriter; nested Models can't takedefault=ordefault_factory=(Gotcha #21). For collections, preferlist[Item]withdefault_factory=listover a single-nested wrapper. - Operations: Map to the right method type:
Reader— read-only queriesWriter— single-state mutationsTransaction— multi-state atomic operations (e.g., transfer between two accounts, or User creating an application type instance)Workflow— long-running control flows with loops, scheduling, and idempotency helpers
- Tool surface: Which operations need UIs (
UI())? Which need explicit tool exposure (mcp=Tool())? - Identity: Single default instance vs multiple instances?
- Cross-state coordination: Does any operation touch
multiple state instances? If yes, use
Transaction.
Key Framework Concepts
User and Application Types
Every AI Chat App has a User type and one or more application
types:
Useris auto-constructed for each authenticated user. Its state is typically empty. Its methods areTransactions that create instances of application types, orReaders that find the IDs of existing application type instances in indexes that have well-known IDs of their own.- Application types (e.g.,
Counter) hold the actual application state. They need acreateWriter withfactory=Truefor construction.
Tool Exposure Control
Every method must explicitly declare its MCP exposure:
mcp=Tool(): Expose the method as an AI-callable tool. Required on every method — includingUsermethods — that the AI should be able to call.mcp=None: Hide the method from the AI. Use for human-only actions or to reduce context bloat.Tool()options:Tool(name="custom_name", title="Title")to override the default tool name or add a human-readable title.
Method Types
UI(): Opens a React UI in the AI chat interface. Takesrequest=(config type orNone),path=(web dir relative to project root),title=,description=. No servicer implementation needed — the React app IS the implementation. Whenrequest=is a Model type, its fields are passed to the React component as props.Writer: Single-state mutations. Context:WriterContext. Usefactory=Trueon thecreatemethod of application types.Reader: Read-only queries. Context:ReaderContext.Transaction: Multi-state atomic operations. Context:TransactionContext. Use when an operation must modify multiple state instances atomically, or when User creates application type instances.Workflow: Long-running control flows. Context:WorkflowContext. Implemented as@classmethod(not instance method). Supportcontext.loop()for periodic/reactive loops, scheduling withtimedelta, and idempotency helpers.
Declarative, Not Decorator
All MCP surface is defined in the API file. main.py is minimal.
No @mcp.tool() decorators.
State is Durable
State survives restarts. Set dev run --application-name=<name> in .rbtrc to
persist across dev restarts. Use uv run rbt dev expunge --application-name=<name> to reset.
Project Structure
<project>/
├── .python-version # "3.10"
├── .rbtrc # Line-based config (NOT YAML!)
├── pyproject.toml # Python deps (uv)
├── api/
│ └── <pkg>/v1/
│ └── <name>.py # API definition
├── backend/
│ └── src/
│ ├── main.py # Application entrypoint
│ └── servicers/
│ └── <name>.py # Servicer implementation
└── web/
├── package.json
├── tsconfig.json
├── tsconfig.app.json
├── tsconfig.node.json
├── vite.config.ts
├── index.css # Theme variables
└── ui/
└── <ui-name>/
├── index.html
├── main.tsx # RebootClientProvider entry
├── App.tsx # React component
└── App.module.css
Step-by-Step Build Flow
Only execute after plan approval. All commands run from the application directory.
-
Create
.python-version,pyproject.toml,.rbtrc -
uv sync -
Write API definition (
api/<pkg>/v1/<name>.py) -
uv run rbt generate -
Write servicer (
backend/src/servicers/<name>.py) -
Write
main.py -
npm create @reboot-dev/ui -
cd web && npm install -
uv run rbt generate(React bindings neednode_modules) -
Customize React UIs: edit
App.tsxfiles inweb/ui/*/ -
cd web && npm run build -
Create
mcp_servers.jsonwith{"mcpServers":{"<name>":{"url":"http://localhost:9991/mcp","useOAuth":true}}} -
STOP. Do NOT run the app yourself. Print the following run instructions exactly, then wait:
Project directory: <absolute path to the current working directory> To run (each in a separate terminal, from the project directory): uv run rbt dev run # start backend cd web && npm run dev # HMR frontend (separate terminal) To test with MCP inspector (separate terminal): npx @mcpjam/inspector@2.4.0 --config mcp_servers.json --server <name>Replace
<name>with the actual server name frommcp_servers.json. Print the real absolute path of the project directory (not the literal placeholder). Then suggest a first prompt the user can try in the inspector (e.g., "Create a new todo list and show it to me").
Inline Patterns
All patterns below are complete and copy-paste-ready. Replace
<project-name>, <pkg>, <name>, <ui-name> with actual values.
.python-version
3.10
.rbtrc
Line-based config. NOT YAML!
# Find API definitions in 'api/'.
generate api/
# Tell `rbt generate` where to put generated files.
generate --python=backend/api/
# Generate React bindings for web apps (into "web/api/").
generate --react=web/api
generate --react-extensions
# Watch if any source files are modified.
dev run --watch=backend/**/*.py
# Tell `rbt` that this is a Python application.
dev run --python
# Save state between restarts.
dev run --application-name=<project-name>
# Run the application!
dev run --application=backend/src/main.py
# Default to HMR mode when no --config is specified.
dev run --default-config=hmr
# Hot Module Replacement (HMR): Vite dev server proxied through Envoy.
# Run Vite in a separate terminal: cd web && npm run dev
# Envoy routes "/__/web/**" to Vite for HMR support.
dev run:hmr --mcp-frontend-host=http://localhost:4444
# Dist mode: serve pre-built artifacts from "web/dist/" (no Vite HMR).
# Usage: uv run rbt dev run --config=dist
# Requires: cd web && npm run build
dev run:dist --mcp-frontend-host=""
# When expunging, expunge that state we've saved.
dev expunge --application-name=<project-name>
pyproject.toml
[project]
name = "<project-name>"
version = "0.1.0"
requires-python = ">= 3.10"
dependencies = [
"httpx>=0.27,<1.0",
"uuid7>=0.1.0",
"anyio>=4.0.0",
"reboot>=1.0.4",
]
[tool.rye]
dev-dependencies = [
"mypy==1.18.1",
"types-protobuf>=4.24.0.20240129",
"reboot>=1.0.4",
]
virtual = true
managed = true
API Definition (api/<pkg>/v1/<name>.py)
Rules:
- Import only the method types you use from
reboot.api - Helper Model types as standalone classes
- State model with
Field(tag=N)on every field Usertype with empty state andTransactionmethods that create application type instances- Application types with their own state and methods
- All methods need explicit
mcp=Tool()(AI-callable) ormcp=None(hidden from AI) - Application types need a
createWriter withfactory=True api = API(User=Type(...), <AppType>=Type(...))
Simple Example (Counter)
from reboot.api import (
API,
UI,
Field,
Methods,
Model,
Reader,
Tool,
Transaction,
Type,
Writer,
)
# -- User models. --
class CreateCounterResponse(Model):
counter_id: str = Field(tag=1)
class UserState(Model):
pass
# -- Counter models. --
class CounterState(Model):
value: int = Field(tag=1, default=0)
description: str = Field(tag=2, default="")
class ValueResponse(Model):
value: int = Field(tag=1)
class AmountRequest(Model):
"""Request with an amount parameter."""
amount: int = Field(tag=1)
api = API(
User=Type(
state=UserState,
methods=Methods(
create_counter=Transaction(
request=None,
response=CreateCounterResponse,
description="Create a new Counter. Returns "
"the ID of the new counter. That ID is not "
"human-readable; pass it to future tool "
"calls where needed, but no need to tell "
"the human what it is.",
mcp=Tool(),
),
),
),
Counter=Type(
state=CounterState,
methods=Methods(
show_clicker=UI(
request=None,
path="web/ui/clicker",
title="Counter Clicker",
description="Interactive clicker UI for "
"the counter.",
),
create=Writer(
request=None,
response=None,
factory=True,
mcp=None,
),
get=Reader(
request=None,
response=ValueResponse,
description="Get the current counter value.",
mcp=Tool(),
),
increment=Writer(
request=AmountRequest,
response=None,
description="Increment the counter by the "
"specified amount.",
mcp=Tool(),
),
decrement=Writer(
request=AmountRequest,
response=None,
description="Decrement the counter by the "
"specified amount.",
mcp=Tool(),
),
),
),
)
Parameterized UI Example
When the AI should pass parameters to a React UI (e.g., a
personalized message or configuration), use request= with a
Model type. The fields become React component props:
class DashboardConfig(Model):
"""Configuration passed by the AI."""
personalized_message: str = Field(tag=1)
# In the application type's Methods():
show_dashboard=UI(
# The AI provides a DashboardConfig when opening this UI.
# The fields are passed to the React component as props.
request=DashboardConfig,
path="web/ui/dashboard",
title="Counter Dashboard",
description="Dashboard UI. Use `personalized_message` to "
"impart wisdom on the topic of counting things.",
),
The React component receives the config fields as props:
import {
type DashboardConfig,
useCounter,
} from "@api/<pkg>/v1/<name>_rbt_react";
export const DashboardApp: FC<DashboardConfig> = ({ personalizedMessage }) => {
const counter = useCounter();
const { response } = counter.useGet();
// personalizedMessage is available as a prop.
return (
<div>
{personalizedMessage}: {response?.value ?? 0}
</div>
);
};
mcp=None Example
Hide a method from the AI (e.g., for human-only actions):
# In an application type's Methods():
# Only callable from the React UI, not by the AI.
confirm_dangerous_action=Writer(
request=ConfirmRequest,
response=None,
description="Confirm a dangerous action.",
mcp=None,
),
List State Patterns
For application types with list-based state (items, entries, messages, etc.):
- Define helper Model types as standalone classes (e.g.,
class Item(Model)) — NOT nested on the application type - Use
list[Item]in the state withdefault_factory=list - Add CRUD Writers: add, remove, toggle, reorder as needed
- Each Writer validates indices before mutating
- The
reorderpattern usespop+insert - In the servicer, import helpers standalone:
from <pkg>.v1.<name> import Item
The counter example above shows the full User + application type pattern. Apply the same structure for any application type, adding whatever Writers and Readers your app needs.
Nested Model State Patterns
For application types that own a single nested Model
sub-object (preferences blob, profile, config, etc.):
- Declare the field as
Optional[Sub] = Field(tag=N, default=None). Nested non-OptionalModeltypes reject bothdefault=anddefault_factory=(Gotcha #21). - Hydrate the sub-object in the parent's factory
createWriter, so callers never observe theNone:
from reboot.api import API, Field, Methods, Model, Transaction, Type, User as RbtUser, Writer
from typing import Optional
class GuestPreferences(Model):
meal_type: str = Field(tag=1, default="")
calorie_level: str = Field(tag=2, default="")
dietary_restrictions: str = Field(tag=3, default="")
class Guest(Model):
name: str = Field(tag=1, default="")
# Single nested Model: Optional + default=None, populated
# by the factory `create` below.
preferences: Optional[GuestPreferences] = Field(tag=2, default=None)
class CreateRequest(Model):
name: str = Field(tag=1)
meal_type: str = Field(tag=2, default="")
calorie_level: str = Field(tag=3, default="")
dietary_restrictions: str = Field(tag=4, default="")
# Servicer side (in `backend/src/servicers/<name>.py`):
class GuestServicer(Guest.Servicer):
async def create(self, context, *, name, meal_type, calorie_level, dietary_restrictions):
self.state.name = name
self.state.preferences = GuestPreferences(
meal_type=meal_type,
calorie_level=calorie_level,
dietary_restrictions=dietary_restrictions,
)
If the prompt suggests plural sub-objects ("each guest's
preferences"), prefer list[GuestPreferences] with
default_factory=list — lists are exempt from this rule.
Workflow Example (Long-Running)
Use Workflow for periodic or long-running operations:
from reboot.api import (
API,
Field,
Methods,
Model,
Tool,
Type,
Workflow,
)
class DoPingPeriodicallyRequest(Model):
num_pings: int = Field(tag=1)
period_seconds: float = Field(tag=2)
class DoPingPeriodicallyResponse(Model):
num_pings: int = Field(tag=1)
# In an application type's Methods():
do_ping_periodically=Workflow(
request=DoPingPeriodicallyRequest,
response=DoPingPeriodicallyResponse,
)
Servicer (backend/src/servicers/<name>.py)
Rules:
- Import helper types standalone:
from <pkg>.v1.<name> import MyItem - Import generated classes:
from <pkg>.v1.<name>_rbt import User, Counter - Each type gets its own servicer class
(e.g.,
UserServicer,CounterServicer) User.Servicer/Counter.Servicerbase- Context types from
reboot.aio.contexts:ReaderContext— read-onlyWriterContext— single-state mutationTransactionContext— multi-state atomicWorkflowContext— long-running (@classmethod)
- Access state via
self.state.<field> - Request types:
Counter.XxxRequest, response:Counter.XxxResponse
Simple Servicer (Counter)
from ai_chat_counter.v1.counter_rbt import Counter, User
from reboot.aio.contexts import (
ReaderContext,
TransactionContext,
WriterContext,
)
class UserServicer(User.Servicer):
async def create_counter(
self,
context: TransactionContext,
) -> User.CreateCounterResponse:
"""Create a new Counter and return its ID."""
# Factory create: pass request fields as keyword args
# directly — do NOT wrap in a Request object.
# No-args: Counter.create(context)
# With args: Counter.create(context, title="...", count=0)
counter, _ = await Counter.create(context)
return User.CreateCounterResponse(
counter_id=counter.state_id,
)
class CounterServicer(Counter.Servicer):
async def create(self, context) -> None:
# State is initialized with defaults; nothing to do.
pass
async def increment(
self,
context: WriterContext,
request: Counter.IncrementRequest,
) -> None:
self.state.value += request.amount
async def decrement(
self,
context: WriterContext,
request: Counter.DecrementRequest,
) -> None:
self.state.value -= request.amount
async def get(
self,
context: ReaderContext,
) -> Counter.GetResponse:
return Counter.GetResponse(value=self.state.value)
Workflow Servicer
Workflow methods are @classmethod — no self, no self.state. To
call back into the current instance, use the state class
imported from <name>_rbt (e.g. MyType.ref()), NOT cls. Inside a
Workflow, calling <StateClass>.ref() with no arguments is special:
it picks up the current state_id from WorkflowContext
automatically, so MyType.ref() resolves to a ref to the running
workflow's own instance.
from datetime import timedelta
from reboot.aio.contexts import WorkflowContext
# Import the state class — this is what `.ref()` is called on,
# NOT `cls`. `cls` inside the classmethod is `MyTypeBaseServicer`.
from <pkg>.v1.<name>_rbt import MyType
class MyTypeServicer(MyType.Servicer):
@classmethod
async def do_ping_periodically(
cls,
context: WorkflowContext,
request: MyType.DoPingPeriodicallyRequest,
) -> MyType.DoPingPeriodicallyResponse:
async for iteration in context.loop(
"Ping periodically",
interval=timedelta(seconds=request.period_seconds),
):
# `MyType.ref()` with no args is Workflow-only magic:
# it reads `state_id` from `WorkflowContext`, returning
# a ref to this workflow's own instance. Do NOT write
# `cls.ref()` or `self.ref()` here — see Critical
# Gotcha #19.
await MyType.ref().do_ping(context)
pings_sent = iteration + 1 # iteration starts at 0.
if pings_sent >= request.num_pings:
break
# `.read()` is only valid on the workflow's own no-arg
# ref; a foreign-state read like
# `OtherType.ref(id).read(context)` raises a "only
# supported within workflows" RuntimeError. Call a Reader
# method on the foreign type instead — see Gotcha #23.
state = await MyType.ref().read(context)
return MyType.DoPingPeriodicallyResponse(
num_pings=state.num_pings,
)
Use inline writers for workflow-only state changes. When the
mutation is only ever performed by this workflow, do not add a
separate store_xxx Writer to the API just so the workflow can
call it. Pass an async (state) -> ... function to
.idempotently("alias").write(context, fn):
async def increment_count(state):
state.num_pings += 1
await MyType.ref().idempotently(
"Increment ping count",
).write(context, increment_count)
The idempotency alias is a human-readable string that survives
workflow restarts — the inline writer runs at most once per
alias. Anti-pattern: defining a store_count Writer in the API
just so the workflow can await MyType.ref().store_count(context)
to bump a counter. That adds an unnecessary indirection.
Reserve declared Writers for operations that are also called
from outside the workflow.
For "run every time" (e.g., re-fetching a remote value on each
loop iteration), use .always().write(context, fn) instead of
.idempotently("...").write(...).
Scheduling a Workflow from a Transaction
A workflow can only be await-ed directly from an
ExternalContext (e.g. a bootstrap script) or from another
WorkflowContext. In any other context — most commonly a
TransactionContext kicking off a workflow on a state it just
created — the workflow must be scheduled, not awaited
directly. Use .schedule() to fire-and-forget from a
transaction:
# In a User's Transaction method that creates a Game and wants
# its autoplay workflow to start running:
class UserServicer(User.Servicer):
async def create_game(
self,
context: TransactionContext,
request: User.CreateGameRequest,
) -> User.CreateGameResponse:
game, _ = await Game.create(context, ...)
# GOOD — schedule the workflow from the transaction.
# Request type is empty (`AutoplayRequest`), so just
# pass `context`:
await Game.ref(game.state_id).schedule().autoplay(context)
# If the workflow request had fields (e.g.
# `do_ping_periodically(num_pings, period_seconds)`),
# pass them as keyword args:
await Game.ref(game.state_id).schedule().do_ping_periodically(
context,
num_pings=10,
period_seconds=1.0,
)
# BAD — wrapping in `request=` raises
# `TypeError: ... got an unexpected keyword argument
# 'request'` (Gotcha #9):
# await Game.ref(game.state_id).schedule().autoplay(
# context, request=Game.AutoplayRequest()
# )
# BAD — awaiting a workflow directly from a Transaction
# raises `TypeError: ... 'Autoplay' is a workflow and
# must be scheduled from a 'TransactionContext' via
# `await [...].schedule([...]).Autoplay(context, [...])`
# (Gotcha #20):
# await Game.ref(game.state_id).autoplay(context)
return User.CreateGameResponse(game_id=game.state_id)
.schedule(when=timedelta(...)) delays the workflow by a
duration. .schedule() with no argument starts it as soon as
the transaction commits.
Same rule from a WriterContext or ReaderContext: use
.schedule(). Only ExternalContext and WorkflowContext can
await a workflow directly.
main.py
Register all servicers (User + application types):
import asyncio
import logging
from reboot.aio.applications import Application
from servicers.<name> import (
CounterServicer,
UserServicer,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
async def main() -> None:
application = Application(
servicers=[UserServicer, CounterServicer],
)
await application.run()
if __name__ == "__main__":
asyncio.run(main())
web/package.json
Use explicit per-UI build scripts as shown below. Do NOT create a
build.js or any auto-discovery wrapper — use npm run build:<name>
scripts directly.
{
"name": "<project-name>-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build:<ui-name>": "vite build --mode <ui-name>",
"build:watch:<ui-name>": "vite build --mode <ui-name> --watch",
"build": "tsc --noEmit && npm run build:<ui-name>",
"build:watch": "concurrently \"npm:build:watch:*\""
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "1.5.0",
"@modelcontextprotocol/sdk": "1.29.0",
"@reboot-dev/reboot-react": "1.0.4",
"@reboot-dev/reboot-api": "1.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.7.0",
"concurrently": "^9.1.2",
"typescript": "^5.9.2",
"vite": "^6.3.5",
"vite-plugin-singlefile": "^2.0.3"
}
}
For multiple UIs, add build:<name> and build:watch:<name> entries
for each UI, and update the build script to chain them:
"build": "tsc --noEmit && npm run build:ui1 && npm run build:ui2"
web/vite.config.ts
CRITICAL: Copy this file EXACTLY. Do NOT refactor, generalize, or
add recursive directory scanning. The flat outDir: "dist" and
output: "${name}.html" pattern is required — nested output paths
will break the MCP server's UI discovery.
// Vite configuration for Reboot UIs.
import fs from "fs";
import path from "path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
// Auto-discover UIs from ui/ directory.
const uiDir = path.resolve(__dirname, "ui");
const uis: Record<string, { input: string; output: string }> =
Object.fromEntries(
fs
.readdirSync(uiDir)
.filter((d) => fs.existsSync(path.join(uiDir, d, "index.html")))
.map((name) => [
name,
{ input: `ui/${name}/index.html`, output: `${name}.html` },
])
);
export default defineConfig(({ command, mode }) => {
// Path alias for API imports (@api/... -> ./api/...).
const resolve = {
alias: {
"@api": path.resolve(__dirname, "./api"),
},
dedupe: ["react", "react-dom", "zod"],
};
// Dev server configuration.
//
// UIs use a double iframe architecture:
// MCP Host -> srcdoc (origin=null) -> iframe (origin=localhost:9991)
//
// The inner iframe loads from Envoy ("/__/web/**"), which proxies
// to Vite. Because the inner iframe has a real origin, Vite's URLs
// work normally. `base: "/__/web/"` ensures all paths route through
// Envoy.
//
// Hot Module Replacement works automatically: Vite's client connects
// to the page's origin, and Envoy proxies WebSocket upgrades to
// Vite. This also works with tunnels (ngrok) since the tunnel
// points to Envoy.
if (command === "serve") {
const port = parseInt(process.env.RBT_VITE_PORT || "4444", 10);
return {
plugins: [react()],
root: ".",
resolve,
base: "/__/web/",
server: {
port,
strictPort: true,
// Listen on all interfaces since requests come through
// Envoy (and tunnels).
host: true,
allowedHosts: true,
},
};
}
// Build mode: `vite build --mode <ui-name>`
const ui = uis[mode];
if (!ui) {
const valid = Object.keys(uis).join(", ");
throw new Error(`Unknown UI: ${mode}. Use --mode with: ${valid}`);
}
return {
plugins: [react(), viteSingleFile()],
build: {
outDir: "dist",
emptyOutDir: false,
assetsInlineLimit: 100000000,
cssCodeSplit: false,
rollupOptions: {
input: ui.input,
output: {
inlineDynamicImports: true,
entryFileNames: ui.output.replace(".html", ".js"),
assetFileNames: ui.output.replace(".html", ".[ext]"),
},
},
},
resolve,
};
});
web/tsconfig.json
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
web/tsconfig.app.json
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@api/*": ["./api/*"]
}
},
"include": ["ui"]
}
web/tsconfig.node.json
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
web/index.css
:root {
--color-bg: #1a1a2e;
--color-bg-dark: #0f0f1a;
--color-border: #2d2d4a;
--color-text: #e0e0e0;
--color-text-muted: #888899;
--color-green: #4ade80;
--color-blue: #60a5fa;
--color-yellow: #fbbf24;
--color-pink: #f472b6;
--color-purple: #a78bfa;
--color-orange: #fb923c;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
monospace;
}
[data-theme="light"] {
--color-bg: #f8f9fa;
--color-bg-dark: #e9ecef;
--color-border: #dee2e6;
--color-text: #212529;
--color-text-muted: #6c757d;
--color-green: #16a34a;
--color-blue: #2563eb;
--color-yellow: #ca8a04;
--color-pink: #db2777;
--color-purple: #7c3aed;
--color-orange: #ea580c;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-mono);
background: var(--color-bg);
color: var(--color-text);
}
web/ui/<ui-name>/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><UI Title></title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
web/ui/<ui-name>/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RebootClientProvider } from "@reboot-dev/reboot-react";
import { ClickerApp } from "./App";
import "../../index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RebootClientProvider>
<ClickerApp />
</RebootClientProvider>
</StrictMode>
);
web/ui/<ui-name>/App.tsx
Generated hook usage — use<Type>() returns reader hooks and
mutation functions. Both reads and writes go directly to the
Reboot backend:
import { useCounter } from "@api/<pkg>/v1/<name>_rbt_react";
// useCounter() connects to the Counter state instance.
const counter = useCounter();
// Reader (WebSocket subscription, auto-updates):
const { response, isLoading } = counter.useGet();
const value = response?.value ?? 0;
// Writer (direct call to Reboot backend):
await counter.increment({ amount: 1 });
For list state, the same pattern applies — use the generated hook for the application type and call its methods:
// Python from_index -> TypeScript fromIndex (camelCase)
await myType.reorderItem({ fromIndex: 0, toIndex: 1 });
await myType.addItem({ text: "New item" });
Full Counter App.tsx Example
import { useEffect, useRef, useState, type FC } from "react";
import { useCounter } from "@api/ai_chat_counter/v1/counter_rbt_react";
import css from "./App.module.css";
export const ClickerApp: FC = () => {
const [isPending, setIsPending] = useState(false);
const counter = useCounter();
const { response, isLoading } = counter.useGet();
const prevValueRef = useRef<number | null>(null);
const [trend, setTrend] = useState<"up" | "down" | "same" | null>(null);
const value = response?.value ?? 0;
useEffect(() => {
if (response?.value !== undefined) {
if (prevValueRef.current !== null) {
if (response.value > prevValueRef.current) {
setTrend("up");
} else if (response.value < prevValueRef.current) {
setTrend("down");
} else {
setTrend("same");
}
}
prevValueRef.current = response.value;
}
}, [response?.value]);
const handleIncrement = async () => {
setIsPending(true);
try {
await counter.increment({ amount: 1 });
} finally {
setIsPending(false);
}
};
const handleDecrement = async () => {
setIsPending(true);
try {
await counter.decrement({ amount: 1 });
} finally {
setIsPending(false);
}
};
const trendIcon = trend === "up" ? "↑" : trend === "down" ? "↓" : "→";
const trendClass =
trend === "up" ? css.trendUp : trend === "down" ? css.trendDown : "";
if (isLoading && response === undefined) {
return (
<div className={css.container}>
<div className={css.loading}>loading...</div>
</div>
);
}
return (
<div className={css.container}>
<div className={css.row}>
<button
onClick={handleDecrement}
disabled={isPending}
className={css.buttonDecrement}
>
−
</button>
<div className={css.valueGroup}>
<div
className={`${css.counter} ${trendClass} ${
isPending ? css.pending : ""
}`}
>
{value}
</div>
{trend && (
<span className={`${css.trend} ${trendClass}`}>{trendIcon}</span>
)}
</div>
<button
onClick={handleIncrement}
disabled={isPending}
className={css.buttonIncrement}
>
+
</button>
</div>
<span className={`${css.syncStatus} ${isPending ? css.visible : ""}`}>
syncing...
</span>
</div>
);
};
web/ui/<ui-name>/App.module.css
.container {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-mono);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 20px 16px;
gap: 12px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
}
.valueGroup {
display: flex;
align-items: baseline;
gap: 4px;
min-width: 80px;
justify-content: center;
}
.counter {
font-size: 36px;
font-weight: bold;
color: var(--color-text);
transition: color 0.15s ease, opacity 0.15s ease;
}
.counter.pending {
opacity: 0.7;
}
.counter.trendUp {
color: var(--color-green);
text-shadow: 0 0 12px rgba(74, 222, 128, 0.25);
}
.counter.trendDown {
color: var(--color-pink);
text-shadow: 0 0 12px rgba(244, 114, 182, 0.25);
}
.trend {
font-size: 18px;
font-weight: bold;
transition: color 0.15s ease;
}
.trendUp {
color: var(--color-green);
}
.trendDown {
color: var(--color-pink);
}
.button {
width: 40px;
height: 40px;
font-size: 20px;
font-family: var(--font-mono);
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.buttonIncrement {
composes: button;
background: var(--color-green);
color: var(--color-bg-dark);
}
.buttonDecrement {
composes: button;
background: var(--color-pink);
color: var(--color-bg-dark);
}
.syncStatus {
color: var(--color-yellow);
font-size: 11px;
height: 14px;
opacity: 0;
transition: opacity 0.15s ease;
}
.syncStatus.visible {
opacity: 1;
}
.loading {
color: var(--color-text-muted);
font-size: 12px;
}
Adapt the CSS module to your app's needs. The CSS variables from
index.css provide consistent theming.
Critical Gotchas
-
.rbtrcis line-based, NOT YAML. Each line is a command with flags. Comments start with#. -
No
__init__.pyinapi/directories. The generator scans all.pyfiles;__init__.pycauses conflicts. -
Field(tag=N)required on every field. Tags must be unique within each Model class. Start at 1. Defaults: State fields must use proto3 zero-value defaults:default=0for int,default=""for str,default=Falsefor bool,default_factory=listfor lists. Non-zero defaults likedefault="red"ordefault=1are NOT supported (protobuf limitation). Set initial values in a servicer method marked as a factory instead. Request/response fields need no default. -
Helper Model types are standalone imports:
from <pkg>.v1.<name> import MyItem— NOTCounter.MyItem(that doesn't exist). -
Generated class only has:
.State,.Servicer,.XxxRequest,.XxxResponse. -
React bindings use camelCase: Python
from_indexbecomes TypeScriptfromIndex. -
Every method requires explicit
mcp=. Usemcp=Tool()to expose a method as an AI-callable tool (required on all types, includingUser). Usemcp=Noneto hide it from the AI. -
Application types need
factory=Trueon theircreateWriter method. -
Method call signatures: pass request fields as kwargs, never wrap in a Request object. This applies to every call shape — factory constructors, regular Writers/Readers/ Transactions, and
.schedule().method()for workflows. Right shapes:Type.constructor_method(context, field=val, ...)Type.ref(id).some_method(context, field=val, ...)Type.ref(id).schedule().my_workflow(context, field=val, ...)- For empty request types, omit fields entirely:
Type.ref(id).schedule().autoplay(context).
Wrong:
Type.constructor_method(context, request=Type.ConstructorMethodRequest(...))Type.ref(id).some_method(context, request=Type.SomeMethodRequest(...))
The
request=kwarg raisesTypeError: ... got an unexpected keyword argument 'request'at runtime. -
npm installbefore secondrbt generate— React bindings neednode_modulesto exist. -
Generated React hook:
use<TypeName>()— e.g.,useCounter(),useInventory(), etc. -
Generated React import path:
@api/<pkg>/v1/<name>_rbt_react -
Generated Python import path:
from <pkg>.v1.<name>_rbt import User, Counter -
Use
--default-config=hmrin.rbtrc(not--default=hmr). -
UI(path="web/ui/<name>")— path is relative to project root. -
UI(request=<ConfigType>)passes config as React component props.UI(request=None)passes no props. -
Register all servicers in
main.py:Application(servicers=[UserServicer, CounterServicer]). -
The requests and responses on the frontend are always Zod types generated from the Python Models.
-
Inside a Workflow classmethod,
clsis the servicer, not the state class. To call methods on the running instance, use the state class imported from<name>_rbt:await MyType.ref().some_method(context). A no-arg.ref()inside a Workflow picks upstate_idfromWorkflowContextautomatically. Do NOT writecls.ref()— it fails withTypeError: <YourType>BaseServicer.ref() missing 1 required positional argument: 'self', becauserefon the BaseServicer is an instance method, not the state-class factory.self.ref()is also wrong because there is noselfin a classmethod. -
Workflows must be scheduled, not awaited, from a
TransactionContext/WriterContext/ReaderContext. OnlyExternalContextandWorkflowContextcanawaita workflow directly. From a transaction that kicks off a workflow on a state it just created, use.schedule():await MyType.ref(id).schedule().autoplay(context). Writingawait MyType.ref(id).autoplay(context)from a transaction raisesTypeError: ... '<Method>' is a workflow and must be scheduled from a 'TransactionContext' viaawait [...].schedule([...]).(context, [...])``. See the "Scheduling a Workflow from a Transaction" example in the Workflow Servicer section. -
Nested
Modelfields can't takedefault_factoryordefault. Two related rules — both raiseUserPydanticErrorat startup, not at field-construction time, so they look like runtime errors but are static schema problems:default_factory=is only supported forlistanddict.Field(tag=N, default_factory=MyModel)raisesField <X> in model <Y> uses default_factory which is not supported for type <T>. Only list, dict types can have a default_factory currently.- A non-Optional
Model-typed field also can't takedefault=, even with an instance:Field <X> in model <Y> is a non-optional Model type and cannot have a default value. Use Optional for Model types with empty default.
The fix is to declare the field optional and construct lazily, e.g.
preferences: Optional[UserPreferences] = Field(tag=N, default=None), then materialize it inside the servicer (or in a factorycreatemethod) when the parent state is first written. -
.per_workflow()is implicit; don't write it. Inside a workflow,MyType.ref().read(context)andMyType.ref().write(context, fn)already pick the right semantics:.always()inside anuntilblock,.per_iteration()inside acontext.loop, and.per_workflow()everywhere else. Only reach for an explicit.per_iteration()(override the default to per-iteration when not inside a loop) or.always()(re-run every time). A plainMyType.ref().per_workflow().some_method(context)adds nothing beyondMyType.ref().some_method(context). -
.read(context)only works on the workflow's own no-argumentMyType.ref(). Inside a workflow,MyType.ref().read(context)reads the workflow's own state via the no-argumentref()(picks upstate_idfromWorkflowContext). A foreign read likeOtherType.ref(other_id).read(context)raisesRuntimeError: read() is currently only supported within workflows— the constraint isn't actually "must be inside a workflow" (you are) but "must be the workflow's own no-argument ref." For cross-state reads, call a Reader method on the target type. The same rule applies to inline.write(context, fn).# GOOD — workflow's own state. state = await MyType.ref().read(context) # GOOD — cross-state read via a Reader method. response = await User.ref(user_id).get_history(context) # BAD — raises the "only supported within workflows" # RuntimeError despite being inside one. Use a Reader. # user_state = await User.ref(user_id).read(context)
Update Flow
When modifying an existing app:
- Read
.rbtrc, API definition, servicer,main.py - Assess state model changes
- Update API definition -> re-run
uv run rbt generate - Update servicer methods
- Update React components
cd web && npm run build