| name | reboot-chat-app |
| description | Use Reboot to build AI Chat Apps (MCP Apps) for ChatGPT, Claude, VSCode, Goose, and others. |
| argument-hint | ["<app-description>"] |
| allowed-tools | Bash, Read, Write, Glob, Grep, Edit |
/reboot-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:
claude plugin marketplace add reboot-dev/reboot-skills
claude plugin install reboot-chat-app@reboot-skills
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-skills": {
"source": {
"source": "github",
"repo": "reboot-dev/reboot-skills"
}
}
},
"enabledPlugins": {
"reboot-chat-app@reboot-skills": true
}
}
Local (repo checked out)
If you have the reboot-skills repo cloned locally:
claude --plugin-dir /path/to/reboot-skills
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
Type with its own state.
- User methods: How does the AI create instances of
application types? Each gets a
Transaction on User
that 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 be Optional[X] = Field(tag=N, default=None)
and hydrated in the parent's factory create Writer; nested
Models can't take default= or default_factory= (Gotcha #21).
For collections, prefer list[Item] with default_factory=list
over a single-nested wrapper.
- Operations: Map to the right method type:
Reader — read-only queries
Writer — single-state mutations
Transaction — 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:
User is auto-constructed for each authenticated user. Its
state is typically empty. Its methods are Transactions that
create instances of application types, or Readers 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 a create Writer with
factory=True for 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 — including User methods — 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. Takes
request= (config type or None), path= (web dir relative
to project root), title=, description=. No servicer
implementation needed — the React app IS the implementation.
When request= is a Model type, its fields are passed to the
React component as props.
Writer: Single-state mutations. Context: WriterContext.
Use factory=True on the create method 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). Support context.loop() for periodic/reactive loops,
scheduling with timedelta, 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 need node_modules)
-
Customize React UIs: edit App.tsx files in web/ui/*/
-
cd web && npm run build
-
Create mcp_servers.json with
{"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 from
mcp_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
User type with empty state and Transaction methods
that create application type instances
- Application types with their own state and methods
- All methods need explicit
mcp=Tool() (AI-callable) or
mcp=None (hidden from AI)
- Application types need a
create Writer with factory=True
api = API(User=Type(...), <AppType>=Type(...))
Simple Example (Counter)
from reboot.api import (
API,
UI,
Field,
Methods,
Model,
Reader,
Tool,
Transaction,
Type,
Writer,
)
class CreateCounterResponse(Model):
counter_id: str = Field(tag=1)
class UserState(Model):
pass
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)
show_dashboard=UI(
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();
return (
<div>
{personalizedMessage}: {response?.value ?? 0}
</div>
);
};
mcp=None Example
Hide a method from the AI (e.g., for human-only actions):
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 with default_factory=list
- Add CRUD Writers: add, remove, toggle, reorder as needed
- Each Writer validates indices before mutating
- The
reorder pattern uses pop + 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-Optional Model types reject both default= and
default_factory= (Gotcha #21).
- Hydrate the sub-object in the parent's factory
create
Writer, so callers never observe the None:
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="")
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="")
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)
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.Servicer base
- Context types from
reboot.aio.contexts:
ReaderContext — read-only
WriterContext — single-state mutation
TransactionContext — multi-state atomic
WorkflowContext — 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."""
counter, _ = await Counter.create(context)
return User.CreateCounterResponse(
counter_id=counter.state_id,
)
class CounterServicer(Counter.Servicer):
async def create(self, context) -> None:
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
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),
):
await MyType.ref().do_ping(context)
pings_sent = iteration + 1
if pings_sent >= request.num_pings:
break
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:
class UserServicer(User.Servicer):
async def create_game(
self,
context: TransactionContext,
request: User.CreateGameRequest,
) -> User.CreateGameResponse:
game, _ = await Game.create(context, ...)
await Game.ref(game.state_id).schedule().autoplay(context)
await Game.ref(game.state_id).schedule().do_ping_periodically(
context,
num_pings=10,
period_seconds=1.0,
)
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.
import fs from "fs";
import path from "path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
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 }) => {
const resolve = {
alias: {
"@api": path.resolve(__dirname, "./api"),
},
dedupe: ["react", "react-dom", "zod"],
};
if (command === "serve") {
const port = parseInt(process.env.RBT_VITE_PORT || "4444", 10);
return {
plugins: [react()],
root: ".",
resolve,
base: "/__/web/",
server: {
port,
strictPort: true,
host: true,
allowedHosts: true,
},
};
}
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";
const counter = useCounter();
const { response, isLoading } = counter.useGet();
const value = response?.value ?? 0;
await counter.increment({ amount: 1 });
For list state, the same pattern applies — use the generated
hook for the application type and call its methods:
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
-
.rbtrc is line-based, NOT YAML. Each line is a command with
flags. Comments start with #.
-
No __init__.py in api/ directories. The generator scans
all .py files; __init__.py causes 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=0 for int, default="" for str, default=False for
bool, default_factory=list for lists. Non-zero defaults like
default="red" or default=1 are 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 —
NOT Counter.MyItem (that doesn't exist).
-
Generated class only has: .State, .Servicer,
.XxxRequest, .XxxResponse.
-
React bindings use camelCase: Python from_index becomes
TypeScript fromIndex.
-
Every method requires explicit mcp=. Use mcp=Tool()
to expose a method as an AI-callable tool (required on all
types, including User). Use mcp=None to hide it from
the AI.
-
Application types need factory=True on their create
Writer 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 raises TypeError: ... got an unexpected keyword argument 'request' at runtime.
-
npm install before second rbt generate — React bindings
need node_modules to 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=hmr in .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, cls is 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 up state_id from WorkflowContext
automatically. Do NOT write cls.ref() — it fails with
TypeError: <YourType>BaseServicer.ref() missing 1 required positional argument: 'self', because ref on the BaseServicer
is an instance method, not the state-class factory. self.ref()
is also wrong because there is no self in a classmethod.
-
Workflows must be scheduled, not awaited, from a
TransactionContext/WriterContext/ReaderContext. Only
ExternalContext and WorkflowContext can await a 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).
Writing await MyType.ref(id).autoplay(context) from a
transaction raises TypeError: ... '<Method>' is a workflow and must be scheduled from a 'TransactionContext' via await [...].schedule([...]).(context, [...])``.
See the "Scheduling a Workflow from a Transaction" example
in the Workflow Servicer section.
-
Nested Model fields can't take default_factory or
default. Two related rules — both raise UserPydanticError
at startup, not at field-construction time, so they look like
runtime errors but are static schema problems:
default_factory= is only supported for list and dict.
Field(tag=N, default_factory=MyModel) raises
Field <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 take
default=, 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 factory
create method) when the parent state is first written.
-
.per_workflow() is implicit; don't write it. Inside a
workflow, MyType.ref().read(context) and
MyType.ref().write(context, fn) already pick the right
semantics: .always() inside an until block,
.per_iteration() inside a context.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
plain MyType.ref().per_workflow().some_method(context) adds
nothing beyond MyType.ref().some_method(context).
-
.read(context) only works on the workflow's own
no-argument MyType.ref(). Inside a workflow,
MyType.ref().read(context) reads the workflow's own state
via the no-argument ref() (picks up state_id from
WorkflowContext). A foreign read like
OtherType.ref(other_id).read(context) raises
RuntimeError: 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).
state = await MyType.ref().read(context)
response = await User.ref(user_id).get_history(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