==> read the guides
Writing a project
Your code lives in your own repo. A push to it is the deploy: the platform builds it, checks it, and switches your service and your pages to the new release, or refuses the push and says why.
The repo
Section titled “The repo”repo.json { "fallback_lang": "en" }flake.nix one call to the shared librarybackend/index.ts your serviceprojects/<slug>/ project.json { "title": "..." } frontend/<slug>.tsx the page db/migrations/NNN_*.sql Postgres, applied to the project's own schema lang/<lang>.json the page's strings (the fallback language is required)The flake is one call:
{ inputs.shared.url = "..."; outputs = { self, shared }: shared.lib.mkRepo { src = self; user = "<you>"; };}The backend
Section titled “The backend”One service per user serves all of their projects. It runs as you, with your own database; each project has its own schema in it.
import { z } from "zod";import { defineUserService, route } from "@cs/contract";
export default defineUserService(() => ({ routes: [ route({ project: "hello", method: "POST", path: "/messages", summary: "Leave a row", input: { body: z.object({ body: z.string().trim().min(1).max(500) }) }, status: 201, handler: async (ctx, req) => { const [row] = await ctx.db.sql`insert into messages (body) values (${req.body.body}) returning *`; return { message: row }; }, }), ],}));- A route describes itself:
summary,input(body and query schemas, Zod),output,status,errors. The platform checks the input before your handler runs and answers400otherwise; the same definition becomes your project's API document. - Data is Postgres, through
ctx.db.sql-- a tagged template whose values are always parameters. Migrations run at start, in order, once each. - Refuse with
throw new UserError(message, status); any other error is logged and answered as a bare500. - Live updates: a socket route and
channel()from@cs/live. - Models:
@cs/models, below. - Logs:
ctx.log, as structured lines.
Models from the backend
Section titled “Models from the backend”Your backend calls the platform's models with @cs/models, no token needed: a socket only your
service can open, counted against your account like the API's calls.
import { llm, jev } from "@cs/models";
const text = await llm.text({ messages: [{ role: "user", content: "hello" }] });
for await (const chunk of llm.stream({ messages, max_tokens: 400 })) { // the last chunk carries `usage`; breaking out cancels the generation}
const answers = await jev.ask(state, { urgent: { type: "noul", instructions: "Does this need an answer today?" },});The bodies are the same as on the API (the reference describes them). A refusal
is a ModelError with its status: 429 with too many calls in flight, 503 when the model is not
running.
The page
Section titled “The page”import { defineProjectElement, Button } from "@cs/ui";import { projectApi } from "@cs/api";
const api = projectApi("hello");
function Root() { return <Button onClick={() => api.post("/messages", { body: "hi" })}>write</Button>;}
defineProjectElement("hello", Root);Use the shared controls (Button, Input, Card, ...) and Tailwind classes; the platform's theme
reaches your page. projectApi(slug) calls your backend, useProjectSocket keeps a live socket,
useProjectT follows the platform's language.
Your page runs in a sandboxed frame: it has no cookies and no browser storage, and cannot open
popups or navigate the page. Calls go through projectApi with a short-lived token the platform
hands it for this project only. Keep your data behind your backend.