Build your first Ramose web app
This guide builds a small React task list. It has one database, one entity, a live local query, and two authoritative operations. The result is also the starter app used by Connect an agent.
1. Create the app
Section titled “1. Create the app”Start with a React application and add Ramose:
bun create vite ramose-tasks --template react-tscd ramose-tasksbun add ramose effect@rcRamose uses Effect Schema for operation input and output codecs, so install effect as a direct dependency.
2. Define the domain
Section titled “2. Define the domain”Create src/domain.ts:
import * as Ramose from "ramose/db"import * as S from "effect/Schema"
export const Task = Ramose.Entity( "task", { title: Ramose.string({ doc: "Short description of the work" }), done: Ramose.boolean(), createdAt: Ramose.timestamp(), }, { operations: (Operation) => ({ createTask: Operation({ self: false, input: S.Struct({ title: S.String }), output: S.Struct({ id: Ramose.EntityId }), doc: "Create a task", run(op, { title }) { const task = op.create({ title, done: false, createdAt: new Date() }) return { id: task } }, }), setDone: Operation({ input: S.Struct({ done: S.Boolean }), output: S.Struct({}), doc: "Set whether a task is complete", optimistic: ({ input, tx, self }) => tx.set(self, Task.done, input.done), run(op, { done }) { op.self.set(Task.done, done) return {} }, }), }), },)
export const ROOT_DATABASE = "tasks"export const App = Ramose.Schema("task-starter", { task: Task })
App.applyPolicy( { roles: ["member"] }, ({ policy, session }) => { const isMember = session.hasRole("member")
policy.task.read.where(isMember) policy.task.operations.createTask.where(isMember) policy.task.operations.setDone.where(isMember) },)
export const deployment = { root: App, deployments: [{ database: ROOT_DATABASE }],}Entity and operation names are durable public identities. Treat renaming them as an API migration, not a display-copy change.
3. Review the policy
Section titled “3. Review the policy”The applyPolicy statement in src/domain.ts permits the member role to read tasks and invoke both operations. A schema accepts one policy statement.
Read permission and operation permission are separate. A caller who can see a task cannot mutate it unless the exact operation is also granted.
4. Deploy the authoritative server
Section titled “4. Deploy the authoritative server”Create peer.ts. This Worker entry installs the named schema and policy:
import * as Effect from "effect/Effect"import { QueryReplicaDO, createServer, createTransactorDO, deployOperationCatalogs,} from "ramose/worker"import { deployment } from "./src/domain"
const operationCatalogs = await Effect.runPromise( deployOperationCatalogs(deployment),)
export default createServer({ operationCatalogs })export { QueryReplicaDO }export const TransactorDO = createTransactorDO(operationCatalogs)Then create alchemy.run.ts to provision the Worker and storage:
import * as Alchemy from "alchemy"import * as Cloudflare from "alchemy/Cloudflare"import * as Effect from "effect/Effect"import * as Layer from "effect/Layer"import * as Ramose from "ramose"
const Store = Cloudflare.R2.Bucket("TasksStore", { name: "ramose-tasks-store",})const server = Ramose.Server("Tasks", { main: import.meta.resolve("./peer.ts"), storage: Store, auth: { jwksUrl: process.env.RAMOSE_JWKS_URL!, issuers: [process.env.RAMOSE_JWT_ISS!], aud: process.env.RAMOSE_JWT_AUD!, allowedOrigins: [process.env.APP_ORIGIN!], },})
export default Alchemy.Stack( "ramose-tasks", { providers: Layer.mergeAll(Cloudflare.providers(), Ramose.providers()), state: Cloudflare.state(), }, Effect.gen(function* () { yield* server }),)Ramose verifies identity tokens but does not issue them. Use your OAuth or sign-in provider’s JWKS URL, issuer, and audience. For a local signer and provider-specific notes, see Authentication.
Deploy or run locally:
bun alchemy dev alchemy.run.tsKeep the printed server URL. The app uses it as its data endpoint; an MCP client will use the same origin with /mcp.
5. Open the local client
Section titled “5. Open the local client”Create src/ramose.ts:
import { createClient } from "ramose/client"import { getSession } from "./auth"import { App, ROOT_DATABASE } from "./domain"
export const client = createClient({ url: import.meta.env.VITE_RAMOSE_URL, database: ROOT_DATABASE, schema: App, auth: getSession,})
export const rootDb = client.open()getSession returns { token, cacheKey }. The stable cacheKey identifies the signed-in account across token renewal. client.open() constructs a handle; it does not fetch the database. The root activates when the first observed query or mutation needs it.
6. Build the React screen
Section titled “6. Build the React screen”Replace src/App.tsx:
import { FormEvent, useState } from "react"import { useQuery, useSyncState } from "ramose/react"import { Task } from "./domain"import { client, rootDb } from "./ramose"
const taskQuery = rootDb.query.from(Task).orderBy(Task.createdAt, "desc")
export default function App() { const tasks = useQuery(taskQuery) const sync = useSyncState(client) const [title, setTitle] = useState("")
function addTask(event: FormEvent) { event.preventDefault() if (!title.trim()) return rootDb.mutate.createTask({ title: title.trim() }) setTitle("") }
if (tasks.status === "pending") return <p>Opening your tasks…</p> if (tasks.status === "error") return <p>{String(tasks.error)}</p>
return ( <main> <p>{sync.status}</p> <form onSubmit={addTask}> <input value={title} onChange={(e) => setTitle(e.target.value)} /> <button>Add task</button> </form> <ul> {tasks.data.map((task) => ( <li key={task.data.id} data-pending={task.local.pending || undefined}> <label> <input type="checkbox" checked={task.data.done} onChange={(e) => task.mutate.setDone({ done: e.target.checked })} /> {task.data.title} </label> </li> ))} </ul> </main> )}On a warm start, useQuery can return stale data immediately from the complete local replica while synchronization resumes. Mutations return durable receipts and may update the local query before they reach the server. The authoritative operation still decides what commits.
7. Verify the offline loop
Section titled “7. Verify the offline loop”Open the app, add a few tasks, then take the browser offline. Toggle a task and add another. The local view updates, pending state remains visible, and the queue survives a restart. Reconnect and the client submits each invocation once, installs the authoritative result, and converges.
The starter is now ready for an agent. Its MCP URL is:
https://your-ramose-origin.example/mcp