Skip to content
sign in

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

repo.json { "fallback_lang": "en" }
flake.nix one call to the shared library
backend/index.ts your service
projects/<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>"; };
}

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 answers 400 otherwise; 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 bare 500.
  • Live updates: a socket route and channel() from @cs/live.
  • Models: @cs/models, below.
  • Logs: ctx.log, as structured lines.

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.

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.