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

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 typeTypeScript type
stringstring
booleanboolean
uint32, int32, float64number
timestampstring (ISO 8601)
elementsT[] (array)
valuesRecord<string, T> (map)
refReference 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.

ServiceDescription
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.

Tip: Include a good description, icon, and screenshots in your roost.json. These are displayed in the app catalog and help users decide whether to install your app.