Building Apps for Roost
Roost apps are full-stack TypeScript applications with a server, a React client, and a shared type schema. The Cameo framework generates type-safe communication between server and client so you can focus on your app logic.
Prerequisites
- Node.js 20 or later
- Yarn package manager
- Roost binary (installation guide)
cameoCLI:npm install -g cameo
Quickstart
Create a new app in under a minute:
# Scaffold a new app
npx cameo init myapp
cd myapp
# Generate types from schema
cameo generate
# Install dependencies
cd server && yarn install && cd ..
cd client && yarn install && cd ..
# Run in development mode
roost dev .
Your app is now running. Edit the server or client code and changes are picked up automatically with hot reload.
App structure
Every Roost app follows the same layout:
myapp/
roost.json # App manifest
schema.json # Type definitions (JTD format)
assets/
icon.svg # App icon
server/
src/
index.ts # Server implementation
interface.ts # Generated: base class + types
migrations/
001_init.sql # Database migrations
package.json
client/
src/
App.tsx # Main React component
lib/
client.ts # Generated: API client + types
package.json
roost.json — App manifest
{
"name": "myapp",
"friendlyName": "My App",
"version": "1.0.0",
"description": "A short description of your app.",
"icons": { "small": "assets/icon.svg" },
"screenshots": ["screenshots/main.png"],
"server": {
"root": "server",
"outputFile": "dist/index.js",
"build": { "dev": "yarn run build", "prod": "yarn run build" },
"watch": ["src"]
},
"client": {
"root": "client",
"build": "yarn run build",
"dev": { "command": "yarn run dev", "url": "http://localhost:3000" },
"outDir": "dist"
},
"publish": {
"destinations": ["apps.roost.club"]
}
}
Schema & types
schema.json defines your app's types, functions, and events
using JSON Type Definition (JTD).
Cameo reads this file and generates both server and client TypeScript code.
Defining types
Use the defs section to declare reusable types. These become
TypeScript interfaces.
{
"defs": {
"todo": {
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"done": { "type": "boolean" }
},
"optionalProperties": {
"dueDate": { "type": "uint32" }
}
}
}
}
Supported JTD types
| JTD type | TypeScript type |
|---|---|
string | string |
boolean | boolean |
uint32, int32, float64 | number |
timestamp | string (ISO 8601) |
elements | T[] (array) |
values | Record<string, T> (map) |
ref | Reference to a defs type |
Defining functions
Functions are RPC methods your client can call. Each function has typed input and output.
{
"functions": {
"addTodo": {
"input": {
"properties": {
"title": { "type": "string" }
}
},
"output": {
"properties": {
"todo": { "ref": "todo" }
}
}
}
}
}
Defining events
Events are real-time messages the server broadcasts to connected clients.
They use a discriminated union with an eventType field.
{
"events": {
"discriminator": "eventType",
"mapping": {
"todoAdded": {
"properties": {
"todo": { "ref": "todo" }
}
},
"todoToggled": {
"properties": {
"id": { "type": "string" },
"done": { "type": "boolean" }
}
}
}
}
}
Cameo CLI
Cameo is the code generation tool for Roost apps. It reads your
schema.json and generates TypeScript code.
# Install globally
npm install -g cameo
# Scaffold a new app
cameo init myapp
# Generate types from schema.json
cameo generate
Run cameo generate whenever you change schema.json.
This regenerates:
server/src/interface.ts— Base server class with abstract methods for each function.client/src/lib/client.ts— Client API with typed RPC methods and event handlers.
Server API
Your server extends a generated base class and implements one method
per function defined in schema.json. The base class also
provides lifecycle methods you can override.
import { BaseServer } from "./interface"
export class Server extends BaseServer {
async open(userID: string) {
await super.open(userID)
// Called when a user connects
}
async addTodo(input: { title: string }) {
const result = await this.db.run(
`INSERT INTO todos (user_id, title) VALUES (?, ?)`,
this.userID, input.title
)
const todo = {
id: String(result.lastInsertedId),
title: input.title,
done: false
}
await this.events.emitAll({
eventType: "todoAdded", todo
})
return { todo }
}
}
Lifecycle methods
These methods control your server's behavior at different stages.
Override them in your Server class.
open(userID: string): Promise<void>
Called when a client connects via WebSocket. The base implementation
sets this.userID. Override to load user-specific state
or send an initial payload. Always call super.open(userID).
close(): Promise<void>
Called when a client disconnects. The base implementation clears
this.userID. Override to clean up resources. Always
call super.close().
process(msg): Promise<void>
Dispatches incoming RPC messages to the appropriate handler method.
This is auto-generated by Cameo based on your schema.json
functions — you typically don't need to override it.
handleEvent(event: PlatformEvent): Promise<void> (optional)
Called when a platform-level event occurs, such as a new user being added to the Roost instance. Override to react to platform events.
async handleEvent(event: PlatformEvent) {
if (event.eventType === "userAdded") {
// A new user was added — event.userID, event.name
await this.db.run(
`INSERT INTO profiles (user_id, name) VALUES (?, ?)`,
event.userID, event.name
)
}
}
search(params): Promise<SearchResponse> (optional)
Implement full-text search for your app. When defined, your app appears in the global Roost search. The platform calls this method with the user's query and expects structured results back.
async search(params: {
query: string,
userIDs?: string[],
from?: number,
to?: number,
cursor?: string
}) {
const rows = await this.db.all<{
path: string; fragment: string; rank: number
}>(
`SELECT path, snippet(fts, 0, '', '', '...', 32) as fragment,
rank FROM todos_fts WHERE todos_fts MATCH ?`,
params.query
)
return {
items: rows,
total: rows.length,
}
}
work(): Promise<number> (optional)
Background task that runs periodically without a connected client.
Return the number of seconds until the next invocation. The platform
calls work() once on app startup and then again after
the returned interval elapses. You can also trigger it early by
calling this.system.scheduleWork() from any handler.
async work() {
// Sync external data every 5 minutes
const resp = await this.http.get("https://api.example.com/feed")
const items = JSON.parse(resp.body)
for (const item of items) {
await this.db.run(
`INSERT OR IGNORE INTO feed (id, data) VALUES (?, ?)`,
item.id, JSON.stringify(item)
)
}
return 300 // run again in 300 seconds
}
start(): Promise<void> (optional)
Called once when the app is first loaded by the platform, before any clients connect. Use this for one-time initialization such as seeding data or registering interest in files.
Available services
The base class provides these services as properties. See the complete API reference for every method, argument, and return type.
| Service | Description |
|---|---|
this.db |
SQLite database with run(), get(),
all(), and tx() methods. |
this.events |
Broadcast events: emit() (current user),
emitAll() (all users),
emitUser(id, msg) (specific user). |
this.users |
Query users: list() returns all users. |
this.media |
Media processing: extract audio metadata, resize images, transcode video/audio, look up album art. |
this.system |
System utilities: notify(),
readFile(), listFiles(),
scheduleWork(), setBadge(). |
this.http |
HTTP requests: get(url),
saveToFile(url, name). |
this.userID |
The current user's ID (set during open()). |
Client API
The generated client module (client/src/lib/client.ts)
exports an app singleton with typed RPC methods.
import { app } from "./lib/client"
// Call a function (typed input and output)
const { todo } = await app.addTodo({ title: "Buy milk" })
// Subscribe to real-time events
const unsubscribe = app.onEvent(event => {
switch (event.eventType) {
case "todoAdded":
setTodos(prev => [...prev, event.todo])
break
case "todoToggled":
setTodos(prev => prev.map(t =>
t.id === event.id ? { ...t, done: event.done } : t
))
break
}
})
// Clean up when component unmounts
unsubscribe()
Database
Each app gets a SQLite database. Use SQL migrations in the
server/migrations/ directory — they're applied
automatically on first load.
Migration files
-- server/migrations/001_init.sql
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
done BOOLEAN DEFAULT 0,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE INDEX idx_todos_user ON todos(user_id);
Querying
// Insert a row
const result = await this.db.run(
`INSERT INTO todos (user_id, title) VALUES (?, ?)`,
this.userID, "Buy milk"
)
console.log(result.lastInsertedId) // new row ID
console.log(result.rowsAffected) // 1
// Query one row
const todo = await this.db.get<{
id: number; title: string
}>(`SELECT * FROM todos WHERE id = ?`, todoId)
// Query multiple rows
const todos = await this.db.all<{
id: number; title: string; done: number
}>(`SELECT * FROM todos WHERE user_id = ?`, this.userID)
// Transaction
await this.db.tx(async (tx) => {
await tx.run(`UPDATE accounts SET balance = balance - ?`, amount)
await tx.run(`UPDATE accounts SET balance = balance + ?`, amount)
})
Real-time events
Events keep clients in sync. The server emits events, and all connected clients receive them instantly.
// Server: broadcast to all connected users
await this.events.emitAll({ eventType: "todoAdded", todo })
// Server: send to current user only
await this.events.emit({ eventType: "listLoaded", items })
// Server: send to a specific user
this.events.emitUser(userId, {
eventType: "notification",
text: "Someone shared a list with you"
})
// Client: listen for events
app.onEvent(event => {
if (event.eventType === "todoAdded") {
setTodos(prev => [...prev, event.todo])
}
})
File uploads
Roost handles file uploads with chunked transfer and automatic metadata extraction for media files (audio, video, images).
// List uploaded files
const { items, total, pageInfo } = await this.system.listFiles({
search: "vacation",
sortBy: "createdAt",
sortDir: "desc"
})
// Read a file's content
const content = await this.system.readFile(fileId)
// Extract audio metadata
const metadata = await this.media.extractAudio(uploadId)
// { title, artist, album, duration, ... }
// Resize an image
const { fileID } = await this.media.resizeImageWidth(fileId, 800)
Packaging
Package your app into a zip file for distribution:
# From your app directory
roost pkg .
# This creates myapp.zip containing:
# roost.json
# server/dist/index.js
# client/dist/
# server/migrations/
# assets/
# screenshots/
The packager builds both server and client, then bundles everything
needed to run the app into a single zip. Users install it with
roost load myapp.zip.
Publishing
Publish your app to the Roost App Catalog so other Roost users can discover and install it.
# Add a destination to roost.json
{
"publish": {
"destinations": ["apps.roost.club"]
}
}
# Publish (will prompt for email and password)
roost publish .
The first time you publish, you'll be asked to register an account. After that, your app is submitted for review and made available in the catalog once approved.
roost.json. These are displayed in the app catalog
and help users decide whether to install your app.