Building a Karsa app
When a task grows past organizing data into building software — a tool, a dashboard, an automation, a small service — Karsa is where it gets built. Not in a scratch directory. Not against someone else's cloud. Inside the project, on the Karsa SDK.
This is the single most important idea for an agent working in Karsa: the app you build runs caged inside the project, and it talks to infrastructure only through the Karsa SDK. Start with the smallest app below; read on when you need more than a page.
The smallest app: a page
A calculator, a converter, a form, a chart: a tool that's only a page is an app made of its files. Make the app (its record goes through the API), then write its files:
curl -s -X POST $KARSA_API_BASE/rpc -H 'content-type: application/json' \
-d '{"method": "karsa/app/create", "params": {"name": "Mortgage calculator"}}'
→ {"app_id": "app_…", "slug": "mortgage-calculator", "page": "index.html",
"dir": "/…/app/app_…_mortgage-calculator", "url": "/project/proj_…/app/app_…", …}
Then write index.html (and any .css, .js, .json, .svg) into dir, with your usual tools.
- It's an app like any other: on the project's Apps page; opening it (the returned
url) shows its page on its Home, edge to edge. Nothing to set up or enable. The record says which file is the page:ui: {page: index.html}(passpagetokarsa/app/createfor another). - Records through the API, files directly. The app's record (its name, its README
body) changes withkarsa/app/update. Its files are plain files in its folder: write them as you would any file, but stay inside that folder. - Can't touch the disk? (a native app, a remote caller) Pass
filestokarsa/app/create({"index.html": "…"}), or address the app:karsa/app/mortgage-calculator/file/write {"path": "js/app.js", "content": "…"};…/file/list,…/file/get {path}and…/file/delete {path}do the rest. Text files only, up to undefined KiB each. An app's files are its code: only the owner changes them, from outside (never the page itself). - It looks like Karsa: use Karsa's look, don't write your own. Every page gets
@karsa/web-sdkin its<head>, injected by Karsa (no<script src>to add): Karsa's theme and components (below) and the channelwindow.karsa(types:web-sdk/src/karsa.d.ts). It gives you the colours, the fonts, styled HTML, and Karsa's pieces as classes. Don't define a palette, fonts or a background of your own (no:root { --bg: #0f172a }, nofont-family): use the tokens (var(--color-ink),--color-paper,--color-paper-2,--color-bright,--color-bright-dim,--color-mute,--color-line,--color-accent,--color-accent-bg,--color-st-sage/-mustard/-terracotta/-steelfor states) and the components. Then the page matches Karsa and follows its light/dark switch. Your own CSS is for layout only. - The page is walled. It runs sandboxed, with no network: it can't fetch a website, and it can't use your Karsa sign-in. Its one way out is the app's channel,
window.karsa, already on the page: karsa.run("fetch_rates", {base: "USD"})runs one of the app's own fns and resolves with its result;karsa.stream("browser", {action: "stream", tab}, onChunk)runs one and hands you each chunk it sends as it goes (karsa.yieldin the fn), in order, then resolvesdonewith its result;stop()ends it (the Browser's live view: a tab's frames, pushed);karsa.op("karsa/<resource>/<verb>", params)calls a Karsa op as the person using the app: in the app's own folder freely (its collections), outside it only what the app asks for (permissions, below) and the person allowed;karsa.save(data, "photo.png")saves a file to the person's device (data: aBlob, adata:URL, or text). The page can't download from inside its wall, so Karsa does it, once the person allows the app'sdevice/savepermission;karsa.print()prints the page (or saves it as a PDF, from the print dialog), once the person allows the app'sdevice/printpermission; use@media printand.k-noprintto print just the document;karsa.toast("Saved")shows Karsa's short message at the bottom ({error: true}for a failure);karsa.user({id, name}) is who's using it;karsa.onReady(k => …)runs once that's known;karsa.route({page, item?, sub?}) is which of the app's pages this is (home, or one of itsui.pages, below), the item it shows, and which of the item's own pages;karsa.open("tab", id)goes to another of the app's own pages (home,overview, one of its collections, one of its pages, with an item or not), andkarsa.open("tab", id, "console")to one of an item's own pages, never outside the app;karsa.title("Example Domain")names what an item's page shows, in the bar above it.- Any of these rejects with an
Errorwhen the person says no (or turned it off), or the call fails: catch it and say so (.catch(e => karsa.toast(e.message, {error: true}))). - A tap that calls Karsa shows it's working, with nothing for you to write. When a tap (a click, a form's submit) calls
karsa.op,karsa.runorkarsa.save, the button tapped gets a spinning ring and ignores more taps. It stays that way until the call is done, and until what the page calls straight after it is done too (its redraw'skarsa/<c>/list). A select or checkbox that saves as it changes spins on itslabel. So: - call Karsa from the tap's own handler;
- don't disable the button or change its label yourself;
- for anything else (a call that starts later, a row being worked on), use
karsa.busy(el, promise). It markseluntil the promise settles and resolves as it does. - What a call returns:
karsa.opresolves with the op's result itself, no envelope: karsa/<c>/list→ an array of records, each flat: the fields you saved plus the server'sidand_created_at/_updated_at([{id: "invoice_01…", client: "Budi", total: 110, status: "draft", _created_at: "…"}]). Not{result: […]}, and nofrontmatterwrapper. It takesqto filter, sort and limit:{q: "status = 'paid' order by _created_at desc limit 50"}.karsa/<c>/get {id}→ one record (same shape);karsa/<c>/create {frontmatter}→{id, …}(the new record's id);karsa/<c>/update {id, patch}→{id, fields_changed};karsa/<c>/delete {id}→{ok: true}.- Values keep their type: numbers stay numbers, and lists and objects are fine as they are (
line_items: [{desc, qty, price}]). - Needs to remember things? In collections of its own, in its folder: nothing to declare. Once,
karsa.op("karsa/collection/create", {slug: "note"}); thenkarsa.op("karsa/note/create", {frontmatter: {title: "…"}}),karsa.op("karsa/note/list"). Bare names always mean the app's own folder; the records are marked as the app's. (The browser'slocalStorageisn't available to a walled page.) - Needs a website (live rates, an API)? Not from the page: give the app a fn that does it, with
allow: ["net"], and call it withkarsa.run. Fns are added by the owner, from outside the app (a page can't give itself the network):
`` karsa/app/currency-calc/fn/create {"slug": "fetch_rates", "allow": ["net"], "code": "import { defineFn } from \"@karsa/sdk\";\ndefineFn(async (input) => { const r = await fetch(https://api.frankfurter.dev/v); return await r.json(); });"} /latest?base=${input.base}); return await r.json(); });"} ``
A fn that reaches outside Karsa is non-native, so the app becomes non-native, and its Overview says why. A fn that uses the internet always asks the person first: list it in a permission with a reason (below), or Karsa asks with "The app didn't say why."
- Needs the project's data, a Karsa capability, the internet, or to save files? The app asks for it, with a reason, like an iOS app (spec_
karsa/app/createMundefinedDHKKundefinedCGB). Declarepermissions(onkarsa/app/create, or later withkarsa/app/update {id, patch: {permissions}}):
``json "permissions": [ {"name": "Save files to your device", "why": "Save your watermarked photos", "ops": ["device/save"]}, {"name": "Print or save as PDF", "why": "Print an invoice or save it as a PDF", "ops": ["device/print"]}, {"name": "Read your memos", "why": "Show them on the dashboard", "ops": ["memo/list", "memo/get"]}, {"name": "Use the internet", "why": "Get today's exchange rates", "fns": ["fetch_rates"]} ] ``
Each has a name the person reads ("Save files to your device"), a why (required: what it's for), and what it covers: ops (Karsa ops outside the app, resource/verb or resource/*; or the device's device/save, device/print) and/or fns (its own fns that ask before they run). A page that prints must list device/print (it's asked when the app opens, because the page's sandbox is set as it loads; Karsa adds it with "The app didn't say why" if the page calls print() without listing it). The page then calls karsa.op("karsa/project/_/memo/list") (project/_ = its project) or karsa.save(…) as usual. The first time a call needs a permission, Karsa shows the person a sheet ("Photo Watermarker wants to save files to your device", your why, Allow / Don't allow) and remembers the answer. A call runs as the person, never beyond what they could do themselves. Each person can change their answers on the app's Permission page. Ask only for what the app uses, and say plainly why.
- Check the page before you say it works (spec_undefinedMundefinedDNXPSYZG). A script that doesn't parse never runs: nothing on the page works and the person sees nothing.
- Karsa refuses a page write through the API whose script doesn't parse, naming the file, line and column.
- After any change, run
karsa/app/check {id}.page.problemslists scripts that don't parse, including files you wrote straight intodir.page.errorslists errors the page hit while someone used it (the Home page shows them to the person too). - Testing a fn with
karsa/run/createtests the fn, not the page: the page still has to call it, and the person is asked for its permissions (e.g. "Use the internet") the first time. - Tell the person what you actually tested.
- Never build it in
/tmpor a scratch folder:karsa/app/creategives it a folder where the Home page serves it.
Karsa's components, for a page
The same pieces Karsa's own pages use, as classes on plain HTML (@karsa/web-sdk's karsa.css, spec_karsa.cssMundefinedDKMBundefinedY@karsa/web-sdkD). They work on a phone first.
| Class | What it is | |---|---| | .k-page | The page's column (centred, a comfortable width). | | .k-bar | The top bar: a title, then actions (<span class="k-spacer"> pushes them right). Stays at the top. | | .k-stack, .k-row, .k-grid | Vertical stack, a wrapping row, a grid that collapses to one column on a phone. | | label.k-field | A field: its label above its control, full width (<label class="k-field">Client <input></label>). | | button, button.secondary, button.ghost, button.danger | The main action (accent), a quiet one, a plain one, a destructive one. | | .k-list + .k-item | A list of rows like Karsa's: .k-item-main holds .k-item-title and .k-item-meta (both truncate); aria-current="true" marks the open one. A button.k-item is a tappable row. | | .k-badge (.ok, .warn, .bad, .info) | A status with a dot: <span class="k-badge ok">paid</span>. | | .k-pill, .k-tabs | A pill (aria-pressed="true" / .on when chosen), and a sideways-scrolling row of them for tabs or filters. | | dialog.k-sheet | A bottom sheet on a phone, a panel on a desktop: sheet.showModal(), sheet.close(). Use it for editing, instead of a sidebar. | | .k-empty | The empty state ("No invoices yet."). | | .card, table (in .k-scroll), .eyebrow, .muted | A panel, a table that scrolls sideways on a phone, a small caps label, quiet text. | | .k-noprint | Hidden when printed. |
A starter page, with a list, an editing sheet and its own collection:
<!doctype html>
<html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>Invoices</title></head>
<body>
<main class="k-page">
<header class="k-bar"><h1>Invoices</h1><span class="k-spacer"></span><button id="add">New</button></header>
<ul class="k-list" id="list"></ul>
<p class="k-empty" id="empty" hidden>No invoices yet.</p>
</main>
<dialog class="k-sheet" id="sheet">
<form class="k-stack" id="form">
<h2>New invoice</h2>
<label class="k-field">Client <input name="client" required></label>
<div class="k-grid">
<label class="k-field">Total <input name="total" type="number" step="0.01"></label>
<label class="k-field">Status <select name="status"><option>draft</option><option>sent</option><option>paid</option></select></label>
</div>
<div class="k-row"><span class="k-spacer"></span><button type="button" class="secondary" id="cancel">Cancel</button><button>Save</button></div>
</form>
</dialog>
<script>
const $ = (id) => document.getElementById(id);
async function render() {
const invoices = await karsa.op("karsa/invoice/list", { q: "order by _created_at desc" }); // an array of records
$("empty").hidden = invoices.length > 0;
$("list").replaceChildren(...invoices.map((inv) => {
const li = document.createElement("li");
li.className = "k-item";
li.innerHTML = `<div class="k-item-main"><div class="k-item-title"></div><div class="k-item-meta"></div></div><span class="k-badge"></span>`;
li.querySelector(".k-item-title").textContent = inv.client;
li.querySelector(".k-item-meta").textContent = `$${Number(inv.total || 0).toFixed(2)}`;
const badge = li.querySelector(".k-badge");
badge.textContent = inv.status;
badge.classList.add(inv.status === "paid" ? "ok" : inv.status === "sent" ? "info" : "warn");
return li;
}));
}
$("add").onclick = () => $("sheet").showModal();
$("cancel").onclick = () => $("sheet").close();
$("form").onsubmit = async (e) => {
e.preventDefault();
const f = Object.fromEntries(new FormData(e.target));
try {
await karsa.op("karsa/invoice/create", { frontmatter: { ...f, total: Number(f.total || 0) } });
$("sheet").close(); e.target.reset(); karsa.toast("Saved"); render();
} catch (err) { karsa.toast(err.message, { error: true }); }
};
karsa.onReady(async () => {
await karsa.op("karsa/collection/create", { slug: "invoice" }).catch(() => {}); // once: it may already exist
render();
});
</script>
</body></html>
Its records also show in Karsa, with nothing to build: each of the app's collections is one of the app's pages, in its sidebar (Invoice here), listing the records newest first with the filter bar; each record opens its own page, all its fields laid out, at /project/<project>/app/<app>/invoice/<record id>. So that a record reads well there:
- Give it a title field. A row's title is the record's first of
title,name,label,number,subject(else its id), so an invoice shows asINV-001, notinvoice_01M4…. - Keep a
status(draft,sent,paid) to show it as a badge on the row and the page. - Store numbers as numbers and times as ISO strings (
2026-10-08,_atnames): the filter bar then offers number and time filters. - A list kept as JSON text (
line_items) shows pretty on the record's page.
A tool that needs code running without the page open (a schedule, a webhook, an agent) needs fns: read on.
The shape of a Karsa app
A Karsa app is a per-project program that the daemon runs as a subprocess and surfaces in the UI (today via an iframe). It is caged: it gets capability through a gatekept SDK surface, not raw access to the machine. That cage is a feature — it means an app can be powerful without being dangerous, and it means "expand to the cloud later" is a config change, not a rewrite.
You build the app in the project tree (under karsa/proj_*/), versioned in git like everything else. Its data lives in the project's sqldb/. Its files live in the project. Nothing escapes the cage by default.
The SDK is the whole interface to infrastructure
Everything an app needs from the outside world comes through @karsa/* SDK packages. You do not open sockets, spin up servers, or sign up for external accounts. You call the SDK:
@karsa/sqldb— relational storage. This is the sole storage chokepoint: it is what makes a local app able to "expand" to the cloud unchanged. Tabular data, app state, anything you'd reach for Postgres for — it goes here.@karsa/docdb— document storage (markdown-truth documents with a derived index). Built on top of@karsa/sqldb, not its own database. Use it for notes, specs, content — anything document-shaped.@karsa/blob— large binary objects: uploads, images, generated artifacts.@karsa/queue— background work and message passing between parts of your app.- Plus the rest of the gatekept surface as you need it: schedule, agents, http, secrets.
Because these are the only doors, the daemon can enforce the cage, swap a local backend for a cloud one, and keep the app portable. An app written to the SDK is an app that survives "let's deploy this for real."
Files-first, indexes derive. Every write lands on disk as the source of truth (a markdown/frontmatter artifact); any SQLite/search index is synced, never authored, and never feeds back — docdb does write-through indexing (file write → immediate DB update), event-driven sync subscribers converge reverse indexes on CRUD, and a boot reconcile is the catch-up. You write the file; the index follows. So a record is always readable from disk alone, and an external edit or a fresh boot re-derives the index — nothing lives only in a database.
How your code executes — code or cli
Every fn has a runtime, one of two, plus a few flags:
runtime: code # TypeScript; Karsa runs it in its own Deno process
entry: handler.ts
allow: [run] # flag: reach outside the wall (run, net, ffi, sys)
runtime: cli # any command line
run: ["opencode", "--version"] # or per platform: { macos: [...], linux: [...], any: [...] }
ipc: true # flag: the program gets KARSA_FN_SOCKET, so it can call Karsa and return a result
codewith no flags is the default and the safe one. A Deno process walled by Deno: it reads its project and Karsa's SDK, writes only its own folder (an app's fn: and its app's.home/, its HOME), sees only the env Karsa hands it (its keys, its config), and can't spawn processes or open arbitrary sockets. Its only channel to the server is the fn SDK (defineFn+karsa.op(...), gatekept per call by the fn'spermissions). Write your logic; call ops; that's the whole surface. Everycodefn speaks it: a handler isdefineFn(...), never a bareexport default.allow:is the explicit exception: a fn that exists to run another program (the agent runners declareallow: [run]). It's visible in the record, never inherited.netandsysadd just those.runandffistart code Deno can't wall, so such a fn's wall is its OS user: it never runs as you.- Whom it runs as. An agent's run is the agent's own OS user, whatever the fn. Anything else of an app's (you on its page, a subscriber, its service, a warm fn) runs as the app's own OS user, never as you; an installed app's setup (its check and install, which put it into Karsa's own folders) is the one thing that runs as you. A project's own fn (one that isn't in an app's folder) that no agent runs runs as you, but only walled:
codewith norunorffi. Anything else runs for an agent, or belongs in an app. Anapp:on a fn record outside its app's folder means nothing. cliruns a command line. Run in the background, it's a tracked exec: its output streams into the exec log and its exit code decides done/failed. Called inline, it returns{ok, code, stdout, stderr}.{fn_dir}inrun:is the fn's own directory, for an executable you ship with the fn. Withipc: truethe program gets the fn socket (KARSA_FN_SOCKET, JSON-RPC both ways), the same channel acodefn uses, so a Rust or Go program can be a full fn.- A run started in the background outlives a Karsa restart. Its process keeps going; a
codefn'skarsa.opcalls,yields and result wait while Karsa is down and go through when it's back, and the restarted Karsa finishes the run. If Karsa isn't back withinyieldminutes, the waiting calls fail. Anipcprogram gets the same by speaking it: onnotifications/karsa/pause, answernotifications/karsa/pausedand hold what you send next; when the socket closes, connect to$KARSA_FN_SOCKETagain; keep your result untilnotifications/karsa/run-receivedarrives (send it again after a reconnect). embedded: <name>(oncode) runs Karsa's own code inside the daemon. Only Karsa ships these (Remote's tunnel service is one), and Karsa runs one only where it installs it: on a record of yours it's refused.
A native fn is code with no flags. Anything else is non-native: Karsa can't wall it.
Every run is an exec (its record and log, on disk, never in git), so a fn that's slow to start and called often should stay warm: declare warm: true (+ idle_timeout_ms, default undefined minutes; 0 never stops it). One process, kept loaded between calls, serves every caller, recorded as one exec per process, not one per call — the choice for a page's fetch_rates. Each call:
karsa.call(fnRef, input). May the caller run it? Every call is karsa/run/create (from any fn, karsa.call(fnRef, input), so the caller needs that op in its permissions), checked like any run. Nothing but Karsa reaches its socket. defineFn. Running? If not, Karsa starts it as the user it belongs to: your app's own user (and, on a machine set up for it, that user's OS account), else whoever made the fn. A native one (defineFn) is walled: it reads its project and Karsa's SDK, writes only its own folder, and sees only the fn IPC's env and its secrets (yours or your app's own, asked for once, when it starts); allow: [net] and friends add what you declare. A non-native one (runtime: cli, ipc: true) is any program that speaks the fn IPC on $KARSA_FN_SOCKET: newline-delimited JSON-RPC, answering tools/call for run. allow: [net]. The request: Karsa sends the input with the caller's user id, karsa.caller.user in your handler (params._meta.caller.user on the wire), and their name in the project (karsa.caller.name), so you know whom you're answering. Your ops act as your app, never as the caller: the caller is information, not authority.
Its exec's log keeps a line per call (its output, cut short) and every failed call with its error. A fn called many times a second (the browser's live view) declares log: errors to keep only the failures.
Ten minutes (idle_timeout_ms) after its last call, Karsa stops it; a crash fails that call and the next one starts it again. Reinstalling the app (a new version of its package) stops its running warm fns too, so the next call runs the new code. Two examples: a render_markdown that loads Shiki's grammars once (~undefined ms) and renders a page in ~undefined ms, called on every page view; an embed written in Python that loads a sentence-transformers model once (~undefined s, ~undefined MB) and embeds a text in ~undefined ms, so agent A's search, your page, then agent B's are three requests to one process.
Keep it running through a Karsa restart: survives_restart: true. A Karsa restart (an upgrade, karsa restart) stops warm fns and services by default: the next call, or the next Karsa's boot, starts a fresh one. A fn whose process holds something worth keeping (open browsers, a loaded model, live connections) declares it:
# fn/browser/index.md (@karsa/browser keeps its Chromes this way)
runtime: code
warm: true
idle_timeout_ms: 1800000
survives_restart: true
allow: [run]
- Karsa's stop pauses it and leaves it running. A call it's serving finishes first (a few seconds at most).
- It waits for Karsa. A
codefn's SDK connects again by itself, for up tokarsa stopminutes, and itskarsa.opcalls wait meanwhile. Past that it exits, so after akarsa stopit's gone withinkarsa stopminutes. - The next Karsa takes it over before anything can call it: the same process answers the next call (a warm fn's idle clock starts over), and the same service runs on (an app disabled meanwhile stops it).
- A call in flight when Karsa stops may fail; the caller tries again.
- For a warm fn, and an app's
servicefn that speaks the fn IPC (code, orcli+ipc). A plaincliservice restarts with Karsa. Anipcprogram does what the SDK does: onnotifications/karsa/pause, answernotifications/karsa/paused; when the socket closes, connect to$KARSA_FN_SOCKETagain. - When not to: a restart is also how you reset something stuck, and only a fresh process gets a new Karsa's SDK. Opt in when what it holds is worth more.
Your app's own OS user. What your app runs on its own (its warm fns, its service) runs as its own hidden OS user, and what it runs for an agent runs as that agent's; its setup (setup.check, setup.install) runs as the owner. Your app's folder is readable by every agent, except its .home/ (its HOME): that is your app's own user's, which writes it, and the owner's, closed to every agent (keep its logins there; @karsa/browser keeps its profiles' cookies there). Its own user also writes its fn/ folders, and a warm fn of yours reads and writes .home/ (Deno.env.get("HOME")). Don't count on anything in the owner's home.
A different agent CLI is just a setting or another fn
There is no machine config for pointing Karsa at a different claude/codex/opencode build. Each harness installation runs the CLI version it pinned (Pin your CLI, below); to use another binary, set the harness app's config.binary (its runner checks that first); for another model or account, install the app again under another name and configure that copy, or install an app that includes it (An app that includes another, below). For a different CLI altogether, build an agent harness app like @karsa/opencode (its runner is one adapter over @karsa/sdk/harness), declare harness: { wake: <its runner> }, and point an agent's wake at its runner (doc_agents).
An app that includes another
An app can include other apps, the way an iPhone app ships a framework inside itself: the framework has its own version, runs in the app's sandbox, and the app's developer decides when to move to a newer one. In Karsa the framework is a whole app, installed for yours as its own installation. Build one when your app is a thin layer over another: a model provider over an agent harness, a preset of a tool, a bundle of a few apps that work together.
The worked example: OpenCode on Mango Inference (@karsa/opencode-mango). OpenCode (@karsa/opencode) is an agent harness that knows nothing about Mango. OpenCode on Mango includes it, sets it up for Mango, and passes it your Mango key. Its whole record:
# opencode-mango/src/app/opencode-mango/index.md
id: tmpl_app_opencode_mango
type: app
name: OpenCode on Mango Inference
includes:
- package: "@karsa/opencode"
version: "^0.2.0" # the versions it was made for: 0.2.x, never 0.3 by itself
as: opencode # its name inside this app
config: # its first config: this app's mapping onto OpenCode's
provider: custom
model: mangoinference/zai-org/GLM-5.3
opencode_config:
provider:
mangoinference:
name: Mango Inference
npm: "@ai-sdk/anthropic"
options: { baseURL: "https://api.mangoboost.io/v1", apiKey: "{env:MANGO_INFERENCE_API_KEY}" }
models: { "zai-org/GLM-5.3": {} }
keys: { MANGO_INFERENCE_API_KEY: MANGO_INFERENCE_API_KEY } # its key ← this app's key
harness: { wake: opencode/opencode_runner } # agents on this app wake OpenCode's runner
secrets:
- env: MANGO_INFERENCE_API_KEY
label: Mango Inference API key
config_schema:
type: object
properties:
model: { type: string, x-options: { fn: mango_models }, description: "A Mango model (zai-org/GLM-5.3), or a line's newest (zai-org/GLM-*)." }
config: { model: "zai-org/GLM-*" }
What Karsa does with it:
- Install installs both.
karsa/package/install {"package": "@karsa/opencode-mango"}installs this app and, for it, OpenCode at the newestkarsa/package/install {"package": "@karsa/opencode-mango"}.parent:.x: its own record (opencode-mango-opencode, withparent:naming this app), its own fns, its own data and its own user. It sits beside its parent in the project'sapp/folder; it isn't in the Apps list, it's under Includes on its parent's page. - It goes with its parent. Enabling, disabling, reinstalling and uninstalling the parent does the same to what it includes. To drop one, change the parent's
includes. - Its parent sets it up. The parent's own user may change itself and what it includes (
karsa/app/update {id, patch: {config}},karsa/app/setup-install), no other app. OpenCode on Mango'smango_syncfn does exactly this: it reads your choice (config.model, an exact model or a line such aszai-org/GLM-*), resolves it against Mango's live list with your key, and writes OpenCode's config when it changed (the mapping is itsmap.ts: the model, and its context and output limits from the list). Two subscribers run it: one every hour (trigger: {every: 1h, catch_up: once}), one on its ownkarsa/app/updated. When Mango can't be reached, or no longer has your model, it changes nothing and says why in its run. - Your key, passed as declared.
keys:says which of the parent's keys the child gets, under which name. You pick the key once, on the parent; that's your yes for both. An agent's own key for the parent works the same way. - Its agents.
harness.wake: <as>/<fn>names a child's runner: install makes@opencode-mango, whose turns run in the included OpenCode. - Each installation is its own actor. OpenCode installed directly and the OpenCode inside OpenCode on Mango are two installations: two users, two configs, two sets of keys and sessions. One can use your OpenCode config while the other uses Mango.
Versions. Packages are kept once per version ($KARSA_HOME/packages/<name>@<version>/); several sit side by side. An included app follows its range: the Updater moves it to the newest version the range allows, and a new major (for 0.x, a new minor) waits until the parent's own new version raises the range. To move one now, even outside its range:
karsa/app/include {"id": "opencode-mango", "package": "@karsa/opencode", "version": "^0.3.0"}
Your changes are kept. Change an installed app's files (its record, its fns' code, its subscribers) and an update keeps your change: each install keeps what the package installed in the installation's .base/ folder, and an update merges your change onto the new version three ways (a record field by field, other files line by line). Your settings (status, config, a subscriber's run mode and schedule) are yours already and never merged. A file you added stays; one you deleted stays deleted. When the new version changed the same lines, nothing of that app is written: its update stops and says which file (an included app's conflict stops only that app); redo the change on the new version, or fork. karsa/app/changes {"id": "opencode-mango"} lists what you changed. karsa/app/fork {"id": "opencode-mango"} makes the installation yours: it stops following its package, keeps everything in place, and its agents keep working.
So an agent can move OpenCode on Mango to a newer OpenCode before its maker does: include the newer OpenCode, see a turn fail, fix the mapping (mango_sync's map.ts), and keep both changes over later updates. (includes[].config is only the child's first config: after install, the child's config is its own, and its parent changes it.)
Pin your CLI
A harness drives a command-line program, and a new version of it can change what the harness reads. So a harness names its CLI and the versions it drives:
cli: { name: opencode, version: "^1.19.0" }
- Each installation runs one exact version, the newest in range when it was installed or last updated (
cli_versionon its record), from$KARSA_HOME/tools/<name>/<version>/. Installations that pin the same version share it. - Your
setup.installfn installs it: it getsKARSA_CLI_RANGEandKARSA_CLI_DIR, finds the newest version in range from the CLI's own releases, checks the file against the shaundefined its maker publishes, and records the version on its installation (karsa/app/update {id, patch: {cli_version}}, so it declarespermissions: {ops: [app/update]}).installCliin@karsa/sdk/clidoes all of that given the CLI's versions and, per version, this machine's file and its shaundefined; a version already there isn't downloaded again. OpenCode's comes from its GitHub releases, Claude Code's fromdownloads.claude.ai. - Every fn of the app gets
KARSA_CLI, the pinned file; your runner runs it, and yoursetup.checkasks it for its version.config.binarystill overrides it. - Behind the agent wall, Karsa opens that file to agents' OS users before a run. The CLI's own data stays in the run's HOME.
Run metadata: the reserved _metadata output field
A fn can report run-level metadata — token counts, cost, durations, anything worth showing on the run's record — by declaring a _metadata property in its output JSON Schema and including it in the returned value. _metadata is reserved: when present, the daemon pulls it off the return value (the caller receives the output without it) and persists it onto the run's exec_ record (metadata: + metadata_source: fn), where the exec detail's Metadata view renders it. No _metadata in your output → nothing happens; there is no separate channel to manage. The agent-harness fns report their token/cost numbers through this same mechanism.
If a fn is also part of an app (stamped app:), it acts as the app's service user — its writes attribute to the app — and its back-calls are gated by the fn's own declared permissions (spec_permissionsMapp:GPXundefinedRDAundefined). See spec_permissionsKXMundefinedXPundefined "App execution model" for the full picture.
How to build, step by step
@karsa/sqldb. Stay in the project. Scaffold the app in the project tree. Do not create it in /tmp, your home directory, or a sibling folder outside Karsa. @karsa/docdb. Pick the right storage. Tabular / relational → @karsa/sqldb. Document-shaped → @karsa/docdb. Files → @karsa/blob. Background jobs → @karsa/queue. Don't introduce a second database. @karsa/blob. Use the SDK for every external need. Need persistence, files, jobs, scheduling? There's an SDK door for it. If you think there isn't, check again before reaching outside — that instinct is usually wrong (see Anti-patterns). @karsa/queue. Keep it caged. Don't try to break out of the cage for "convenience." The cage is what lets the app be trusted and later expanded. karsa/. Version it. It's all plain files under karsa/, in git. Commit as you go.
Local now, cloud later — for free
The reason the SDK matters so much: a Karsa app written against @karsa/sqldb runs locally today and can expand to the cloud without a rewrite, because the storage chokepoint is the seam Karsa controls. The moment you go around the SDK — embed a raw SQLite file, call an external API directly, hardcode a connection string — you break that seam, and "expand" becomes "rebuild."
So: build inside, build on the SDK, and the path to production is already laid.
An app is a caged namespace (spec_undefinedMundefinedXREXundefinedT)
The deepest thing to understand: an app is a caged mini-namespace, not a pile of top-level artifacts. Everything the app owns — its collections, its fns, its subscribers — lives under app/{app_id}/, isolated from the user's and from every other app's. Contrast a template, which is a copy: its artifacts land at the project top level and become yours to edit. An app's artifacts stay the app's.
The cage is the app's world. It has implicit full access to its own subtree, and reaches anything outside only with explicit permission. That single rule — own-namespace-free, everything-else-permissioned — is what makes an app both powerful and safe, and what makes install / reinstall / uninstall clean (they operate on one subtree; nothing collides, nothing duplicates).
Apps are like App Store apps
Think of Karsa as iOS and apps as App Store apps:
- Karsa (the daemon) is iOS. It runs apps and knows nothing about any one of them.
- Installing a package is the App Store download. Nothing runs yet.
- Enabling is the first open. Karsa checks the app is ready, and you say OK.
- An app is a bundle of fns, and Karsa calls each at a fixed moment:
| App part | Karsa calls it… | |---|---| | setup.check — is the app ready? | the app page, enable, boot, karsa doctor | | setup.install — make it ready | only when the owner taps Install | | a subscriber's fn | a matching event (a message, a timer) | | service — kept running | from enable to disable, restarted if it dies | | ui.page — the app's page | when someone opens the app |
Install never runs on its own. Everything that runs is a fn, and every run of an app's fn is an exec you can open.
# app/opencode/index.md
setup:
check: opencode_check
install: opencode_install
homepage: https://opencode.ai/docs/#install
cli: { name: opencode, version: "^1.19.0" } # the CLI versions it drives; each installation pins one
config: { provider: none } # what the owner can change; fns read it as input.app.config
config_presets: [...] # one-click partial configs (the Config page)
harness: { wake: opencode_runner } # an agent harness: its runner is an agent's wake
Subscribers, fns and collections aren't listed in the record: they're files in the package, stamped app: <id> at install.
An app's Overview is its spec. The app record's body is its Overview page: what the app does, the decisions and why. Agents read it before they change an app (a message that names the app brings it along), and keep it current when they do: karsa/app/update {"id": "app_…", "patch": {}, "body": "…"}.
Three examples:
| App | Fn | Runtime | Called when | |---|---|---|---| | OpenCode (wraps the opencode CLI) | opencode_check | cli: $KARSA_CLI --version | the app page, enable, boot | | | opencode_install | code, allow: [net, run]: its pinned CLI | the owner taps Install | | | opencode_runner | code, allow: [run] | a message to @opencode | | Caffeinate (keeps the Mac awake) | caffeinate_check | cli: test -x /usr/bin/caffeinate | the app page, enable, boot | | | caffeinate_service | cli: caffeinate -dims | from enable to disable | | Morning Digest (made up, pure TypeScript) | digest_run | code | a timer, every day at code:opencode |
Karsa-native or non-native
- A Karsa-native app runs purely on a separate, walled Deno, with IPC to Karsa. Every execution, and even every remote call, goes through Karsa IPC (fns and execs). Nothing escapes the wall. In practice: every fn it ships is
codewith no flags. Morning Digest is native. - A non-native app is anything Karsa cannot wall: one of its fns is a
cli, or reaches outside withallow:orembedded:. Your mileage may vary. OpenCode and Caffeinate are non-native. An app's page doesn't count against it: pages are walled (sandboxed, no network, Karsa only through the app's channel). A page app that fetches a website does it from a fn withallow: [net], and that fn makes it non-native.
"Native" is about the wall, not about whether there's a binary. (The fn sandbox isn't a full wall yet either: code fns get unrestricted read, write and env.)
The check
The daemon runs the app's check, and the check's logic lives inside the fn: the command line of a cli check, or the code of a code check. A code check returns {ok, summary?, detail?}. A cli check: exit cli means ready, its last line of output is the summary, its stderr the detail. The app page's Overview shows it with Check again, and, while it isn't ready, Install (when there's a setup.install) and Install it yourself (homepage); enabling is refused until it's ready.
# fn/opencode_check/index.md
runtime: cli
run: ["sh", "-c", "\"${KARSA_CONFIG_BINARY:-$KARSA_CLI}\" --version"]
# fn/opencode_install/index.md: the pinned CLI (Pin your CLI, above)
runtime: code
entry: install.ts
allow: [net, run]
permissions: { ops: [app/update] }
A cli fn sees its app's config as environment variables: config.binary arrives as KARSA_CONFIG_BINARY, so OpenCode's check honors a custom binary with ${KARSA_CONFIG_BINARY:-$KARSA_CLI}. (A code fn reads input.app.config.) A fn that needs the binary finds it itself: config.binary first, then its own list of places, then PATH. Want a deeper check, like a login or a key that works? Make the check do it (claude auth status, or a code fn that tries the key). Karsa's own prerequisites, git and deno, aren't apps.
Package layout — @karsa-app/{name}
The scope names the shape: @karsa-app/* (caged app) vs @karsa-template/* (copied template) vs @karsa/* (SDK libraries). A caged app package:
codex/
karsa.package.json # { "name": "@karsa-app/codex", "kind": "app", "scope": "project" }
src/
app/codex/index.md # the app record (vanity id `tmpl_app_codex`, rewritten on install)
fn/codex_runner/ # a fn the app owns — descriptor + its code files
index.md
runner.ts lib.ts
codex-context/index.md # a collection the app owns (its data: each agent's sessions)
Install materializes all of that under app/{app_id}_codex/ (the cage), minting one id per record, rewriting tmpl_ vanity refs to the minted ids, and stamping each with its package. Reinstall reconciles that subtree in place — same slug → same id → state preserved, never duplicated. Uninstall removes the subtree.
The app's own code is UDS-only; capability lives in fns
The deepest rule of the cage: an app's own code can do nothing but talk to the UDS. No filesystem, no network, no spawning a subprocess. Everything the app reaches — persistence, another chat, the outside world — goes through the gatekept SDK over that socket. To do anything privileged (run a CLI, call a remote host), the app calls a capability fn, and a fn is the only thing that carries real OS power:
- The fn declares what it needs. A capability fn does one specific thing and declares its OS permissions:
allow: [run, net](over the read/write/env baseline). The launcher grants exactly that — on any invocation path, so spawning no longer forces "detached." - Two gates, cleanly split. A capability fn exists — a fn carrying
--allow-runis a privileged artifact, approved at install (you approve the app that ships it) or authored by the owner. May the caller invoke it — the caller holdspermission/run/create(optionally scoped{ fn: … }to a specific fn, spec_permission/run/createM--allow-runGPX{ fn: … }RDAundefined §undefined). The fn is a neutral capability; the permission is per-caller.
So "what can this app touch outside itself?" = the app-user's permission list (user.permissions), DERIVED at install from its fns. codex's runner is such a fn: allow: [run] (it spawns the codex CLI), and the codex app-user holds permission/run/create (to invoke it) + permission/chat/*, permission/msg/*, … (its fns' declared reach).
Fns are the app's API
A fn's code imports @karsa/sdk, the app's backend SDK: import { defineFn } from "@karsa/sdk"; defineFn(async (input, karsa) => { … karsa.op("karsa/<c>/list", {q}) … return result; }). karsa.op resolves with the op's result, as on a page; karsa.yield(chunk) sends a chunk as it goes, to a page that called it with karsa.stream (else into its run's log); karsa.pageChanged(page) says one of your pages' items changed (items, above); newId("tab") makes an id in Karsa's shape for something you keep yourself. That (and its subpaths, e.g. @karsa/sdk/harness) is all a fn can import from Karsa. The page side is @karsa/web-sdk.
An app's public API is its set of exposed fns (app.exports.fn). Anyone permitted executes one through the one primitive, karsa/run/create (gated by permission/run/create) — the app's own subscriber, the cage UI, or another app. Per fn: access: public | private, and a permissions declaration — the permissions the fn HOLDS when it runs (permissions: { docdb: [chat, msg] } → permission/chat/*, permission/msg/*; a flat slug list works too).
A stamped fn (app: tmpl_app_codex) runs as a user: the app's own, or the one its subscriber names (user:, e.g. the agent whose wake it is), so every SDK call it makes is caged to its permissions, and it writes as that user: karsa/msg/create { chat_id, body } stamps _created_by = that user, and the chat renders it via the user's display name. There is no write-as / impersonation of anyone else.
The gate is inferred — you declare nothing (spec_undefinedMundefinedGPXundefinedRDAundefined §undefined)
A permission IS an op. Every op is gated by exactly one permission, auto-derived from the op id (permission/msg/create:permission/msg/create): karsa/msg/create needs permission/msg/create. It is inferred, not declared — a collection/index.md states nothing about permissions, and the requirement can't drift from the op because it is the op. permission/full (everything in that project) always passes; a server-wide op goes by the System project instead (doc_using-karsa, Who reaches what).
Scoping lives on the held grant, never the requirement: a held permission may carry attrs that equality-match the op's input — permission/report/update { ownerId: user_X } granted to user_X = "update only your own rows" (row-level access). Values are static for now; dynamic/relational rules (ownerId === whoever is calling) are code in the collection's on_create / on_update api-filters, not a permission attr. So: the requirement is inferred + attr-free; the grant is where you scope.
Addressing (spec_undefinedMundefinedXREXundefinedT §undefined)
- Bare = your own container, never bubbles. Inside codex,
codex-context/listandfn/otherresolve in the cage. A name that isn't there is not found — it does not fall through to the project. - Cross a boundary with an explicit
{type}/{selector}/prefix:project/_/…(the_means "my nearest project" — no hardcoded id),project/{slug}/…,app/{slug}/…, orproj_{id}/…/app_{id}/…. - A record is addressed by id in a named container: the event hands codex
msg_2346; it reaches it asproject/_/msg_2346. A collection op is{container?}/{collection}/{verb}and the prefix scopes the op — codex writes the chat withkarsa/project/_/msg/create { chat_id }. - Resolution ⟂ permission: the prefix says where; the app-user's
permissionssay may-I.
Chat routing and agents (spec_undefinedMundefinedEundefinedKundefinedQundefinedC, spec_undefinedMundefinedJFHundefinedWCKV)
Routing is purely declarative: no dispatcher, no special "default" subscriber.
msg.mentionedUsersis a core, derived array of user slugs on everykarsa/msg/createdevent: the@slugtokens in the body that resolve to people and agents in the project (an email[email protected]is not a mention; an app's own user isn't a handle). A subscriber does an array-includes match on it; that's the whole router.- The default falls out of a pointer. Set
default_ai_reply_user_idon thechatcollection (project default) or a chat record (per-chat override, chat wins). For an unmentioned human message,fn_msg_createinjects that default slug intomentionedUsers, so the default agent's ordinary subscriber fires. Any explicit@mentionsuppresses it. - An agent is a user (
kind: agent): a folder with its persona (index.md's body),docs/andcontext/, andwake, a harness app's runner. Karsa gives it two subscribers (notify: chat, mentions;notify: task, tasks the queue hands it), both running itswakeas it,detach: true. "Add a Reviewer" =karsa/user/createwith a persona and awake; "talk to it" =@reviewer; "turn it off" = pause its subscribers.doc_agentshas it all. - The runner learns which agent it is from
input.actor(the subscriber'suser) orparams.agent, keys its sessions by agent and chat (or task), and replies as it.
The same primitives compose beyond AI replies:
- A mention-subscriber is a notification system. A human user + a subscriber matching
mentionedUsers: <their-slug>running a delivery fn = "@mention someone, their phone pings." A Slack app's outbound half is exactly this — the fn posts to Slack's API through the egress proxy, so the Slack token stays asecret_the fn never sees. - Inbound bridges are the reverse composition. The Slack app creates an ingress (a Karsa Cloud webhook URL,
karsa/ingress/create {project}) assigned to the project, Slack posts to it, the tunnel delivers it askarsa/ext/ingresson that project's bus, and an app subscriber writes the message viakarsa/msg/create. Loop safety falls out: the bridged message is authored by the app-user (author_kind: service), so it never triggers the default-AI injection orauthor_kind: humanmatchers — two bridges can't ping-pong. If AI should react to bridged messages, that's the bridge app's explicit choice, not an ambient default.
Apps: services and server-wide apps
Everything above applies to every app. Two more things matter for an app that keeps something running, like the reverse tunnel (Remote) or Caffeinate: its service, and its scope (a server-wide app lives in the System project, one per server). The point of this section is the pattern: after reading it you (or your agent) can mint a new app without touching Karsa's core.
The record is the desired state
An app is one dir-shaped record in a project's app collection — an id-named dir app/app_<ULID>_<slug>/index.md (the record carries a reserved _slug), body = the app's README:
id: app_<ULID>
type: app
name: Remote
status: disabled # enabled | disabled — the DESIRED state, the only field you toggle
setup:
check: remote_check
service: remote_service # the fn Karsa keeps running while enabled
config: # non-secret config the app's fns read
local_port: 1688
secrets: # NAMES only — values NEVER live in the record
- bind_key
You never write "start the process" anywhere. The record is the desired state; Karsa converges reality to it.
App anatomy — the stamp is the manifest
> An app never introduces new kinds of things — it installs the same fns, collections, and subscribers you already know, stamped app: so Karsa can show, upgrade, and remove them as one unit.
That rule is the whole packaging model. An app is deconstructed: its parts are ordinary artifacts in their native collections — a fn in fn/, a subscriber in subscriber/, a collection, an agent user — each carrying app: app_<id> in its frontmatter. The app record is the anchor and the lens, never a container: nothing "lives inside" the app.
- Where to look. Any list op takes
{ app: app_<id> }—karsa/subscriber/list { app }is the app's subscribers in place.karsa/app/inspect { id }assembles the whole thing in one call: the record, the parts grouped by collection, the exports. The stamp query IS the manifest — there is no separate manifest file to drift. In the web, stamped rows carry anappchip linking back to the app, and the app's page shows the assembled lens. exportsis the public surface. The record'sexports: { fn: [...], collection: [...] }(singular keys) names what the app offers outward — later, its MCP tools/resources. A stamped part that isn't exported is an internal.- Stamped parts take a deliberate change.
update/deleteon a stamped artifact refuse unless you passforce: true; a change you make that way (or by editing its files) is kept over the app's updates: an update merges it onto the new version, and stops for that app when the new version changed the same lines (An app that includes another → Your changes are kept).karsa/app/changes {id}lists them. To stop following the package altogether,karsa/app/fork {id}: the installation is yours, in place. - Removal is the same query. Disabling stops the process; the parts stay installed. Uninstall (coming) is delete-by-stamp — the confirmation lists exactly what goes, because the query is the manifest.
The service: Karsa keeps it running
Flipping the toggle is just a patch (karsa/app/update {id, patch: {status: "enabled"}}). Karsa runs the check, refuses if it isn't ready, then flips the app's subscribers to running and starts its service fn. A service runs until it exits: Karsa restarts it with capped backoff (undefined ms → undefined s), re-starts it at boot while the app (and its project) is enabled, and sends it a clean SIGTERM on disable.
A service is just a fn, so it can be anything: a binary (cli), a Deno web server (code), or Karsa's own code (code + embedded, like Remote's tunnel). It runs as a supervised exec_, so it shows up in the exec collection like any other run: pid, status, and its stdout/stderr as the exec log.
Keys: you ask for a kind, the owner picks one
The record lists the keys your fns need, never values: secrets: [{env: MANGO_INFERENCE_API_KEY, label: "Mango Inference API key", why: "To call the model your agent runs on."}] (a bare NAME works too, labelled by its name). env is the variable your fns read it from; label and why are what the owner sees. A preset's secret: NAME says that preset's config needs it, so while another preset is active it isn't asked for; each {env:NAME} in a pasted config is asked for too.
The owner picks which of their secrets fills each key, on your app's Overview (Keys), and, if they like, a different one for an agent on its page. Picking is the yes: there's no prompt at run time. A secret is just a named value ("Mango — personal"); it carries no env name, so it never reaches your app by matching a name. A run gets, for each key you need now, the pick of the agent it runs for, else your app's, in env, never argv, so nothing leaks through ps or the artifact tree. A key with nothing picked isn't passed: say so where the person will see it (a harness says it in the chat). Your setup fns (setup.check, setup.install) get no keys. Remote's bind_key is different: derived from the server's root key (in the OS keychain), it exists on no disk at all.
The free app page
Every project has an Apps page (sidebar → Apps): the apps installed there and the packages you can install. An app has pages, listed in the sidebar when you open it (/app/<id>/<page>), built from what the app declares, with no per-app UI code. It opens on Home when the app has a page of its own (like opening an app on a phone), else on Overview; an app can choose with ui.default_tab (below).
| Page | Shows | |---|---| | Home | Only when the app has a page (ui.page): its own page, caged as the app's user, edge to edge under a slim bar (its other pages are in the sidebar). For what the common form can't show. | | Its other pages | One per entry of ui.pages (below), after Home: walled and edge to edge like Home. One item on it (…/app/<app>/<page>/<item>) opens in its item_page, under the slim bar; one of the item's own pages (…/<item>/<sub>) in its item_pages. | | Overview | Status (Enable/Disable), the check's result (Check again), the installer (shown only when the check fails: its command first, then its exec tailed live), the service (running, with its exec), the keys it needs (pick one of your secrets, or New key…; each says where it's used already), the chat default, the app's actions (buttons), native or not and why, the package with Reinstall and Uninstall. Then the README. | | Its data | One page per collection in its folder (an invoice app's Invoice): the records, newest first, with the filter bar (fields from the records); each opens its own page (…/app/<app>/<collection>/<record id>) with all its fields. Automatic: nothing to declare (see "Its records also show in Karsa" above to make them read well). | | Config | Only when the app has something to configure: its config_presets (one-click partial configs), then a form built from its config_schema. The daemon validates the whole config on save and refuses a literal key. | | Function | The project's fn list (/fn), filtered to the app: open one to run it. | | Subscriber | The project's subscriber list (/subscriber), filtered to the app: open one to pause, resume or step it. | | Permission | What the app asks for, like iOS Settings › the app: each permission's name, the app's why, what it covers, and your switch (Not asked yet, Allowed, or off). Then, read-only for now, what its fns reach when they run on their own. |
What an app may do has two parts (spec_karsa.run("fetch_rates")Mdevice/saveDHKKundefinedCGB). Inside its own folder (its collections, its fns, its files) it needs no permission: bare names (karsa/note/create, karsa.run("fetch_rates")) always resolve there. Anything that crosses out — its project's records (karsa/project/_/…), a Karsa capability (cloud/link), saving to the device (device/save) — and any fn that uses the internet must be covered by one of its permissions, each with a reason; Overview lists them under Asks for. When a person uses the app (its page), each call runs as that person, within what the app asks for and that person allowed: Karsa asks the first time, and the person's answers live on their own record (an app can't change them). A refused call says which permission and whether it was turned off. When no one is using it (its subscribers, the replies it authors), it acts as its own service user, which holds the declared ops (switches for background work come next). An app never asks for everything (full).
Its UI settings sit under ui:, the same place every entity keeps them (a collection's index.md too): ui.page, the page shown on Home; ui.pages, its other pages; ui.default_tab, the page it opens on (home, overview, config, function, permission).
# app/browser/index.md: Home, a Tab page listing tabs, one tab full screen, its console,
# and the tabs in Karsa's sidebar
ui:
page: ui/index.html
pages:
- title: Tab
slug: tab
page: ui/tabs.html
item_page: ui/tab.html
item_pages:
- { title: Console, slug: console, page: ui/console.html }
items: { fn: browser, input: { action: items } }
Each page is a file in the app's folder and gets the kit like Home; karsa.route tells it which page, item and item's page it is ({page: "tab", item: "tab_01…", sub: "console"} on …/tab/tab_01…/console), and karsa.open(slug, item?, sub?) goes between them. An item's pages are one level deep: a tab and its console, its network later. Use one for live state that isn't records (a browser's open tabs).
A page's items in Karsa's sidebar (items: {fn, input?}): on one of the page's items (…/tab/tab_01…), Karsa's sidebar lists the page's items, as it lists a collection's records. Karsa calls that fn of yours with input, as the person, and shows what it returns: [{id, title, subtext?}], in order, at most undefined ([{id: "tab_01…", title: "Example Domain", subtext: "example.com · you"}]); a row opens …/<page>/<id>. When they change, your fn calls karsa.pageChanged("tab") (any time, from a warm fn too, outside a call; the fn declares it, permissions: {ops: [app/page-changed]}) and Karsa's open pages ask again. Without items, the sidebar lists nothing there. Records belong in a collection, which gets its page by itself. Which opens works one way everywhere: the URL's page wins (/app/<id>/config), then the entity's ui.default_tab, then the page its collection marks default, then the first.
The line between common and the app's own: Karsa reads only the record's top-level fields, the same for every app (name, status, setup, service, ui, permissions, homepage, secrets, config_schema, config_presets, actions, harness, tools, exports, includes, cli). Everything inside config is the app's own: described by its config_schema, read only by its fns.
# app/opencode/index.md
config: { provider: none } # the first install's config; a reinstall keeps yours
config_schema:
type: object
properties:
provider: { type: string, enum: [custom, none] }
model: { type: string, description: "provider/model, as OpenCode's --model takes it" }
config_presets:
- { label: "My opencode.json (paste it in opencode_config below)", config: { provider: custom } }
- { label: "This machine's OpenCode config", config: { provider: none } }
harness: { wake: opencode_runner } # an agent harness: install makes a starter agent on it
A field's choices can come from your app. x-options: {fn: <slug>} on a config_schema property makes the Config tab call that fn of yours (karsa/app/config-options {id, field}, as the person, before the app is on too) and offer what it returns, [{value, label, detail?}]; a value typed by hand still works. OpenCode on Mango's model lists the models your Mango key can use this way, each line's newest first.
Show a setting where people work: ui.quick. ui: {quick: [model]} on your app's record names the config_schema fields people should be able to change without opening your Config page. When your app's agent answers a chat, the sheet behind who replies (the pill above the chat box, "OpenCode on Mango · GLM ▾") shows those fields, with their choices (from x-options, or the field's enum), and a pick saves your app's config (it's your app's setting, for every chat it answers). The Mango apps mark model. karsa/chat/replier {id} returns a chat's replying agent, its app and those fields.
Tools for agents: tools:. An app that offers agents a tool lists its fns there, each with when to use it. Every agent of the project is told on each turn (the primer names each enabled app's tools, with the fn's id), and calls one with karsa/run/create. Put how to use it in the fn's input schema and the app's Overview. @karsa/browser (doc_browser):
tools:
- fn: browser
about: A web browser (headless Chrome), for a page that needs JavaScript or a login. `{"action": "open", "url": "…"}` …
Whose a record is, in your app's collections. A collection in your app's folder keeps its access like any other (access: own in its index.md): an agent lists, reads, changes and deletes only the records it owns (_ownerId); the owner reaches all. Your app keeps them all: its own user (its fns, its service), working in its folder, reaches every record of its own collections and may make one for any user of its project (create {frontmatter, owner}). So a fn decides who uses which, by karsa.caller (karsa.caller.owner is true for the owner, the person). karsa/<c>/delete {id} works there too.
A fn has three pages, for every fn (a project's or an app's): Overview (the record), Run (a form built from its input schema; its log streams live underneath; its result shown per its output schema) and Run History (its runs). An app's fns live in its folder, and their ids (and their runs') resolve from the project like any other.
The same app, twice. A project-scoped package installs again under another name (the Apps page's Install another, or karsa/package/install {package, as}): its own config and folder (and, for a harness, its own starter agent, @<name>), so you can try GLM next to Kimi. A seed names its own app with {app_slug}. It gets no key until you pick one on its page.
Agent harnesses (OpenCode, Claude Code, Codex) run one shared agent turn (@karsa/sdk/harness: what it's about — a chat mention, a task handed to the agent, or an instruction — the key check, the chat's title, the primer, the session, the watchdog, the reply or why it failed); each app's runner supplies only an adapter, how to call its CLI and how to read its output and errors. They tell their model about Karsa through one shared primer (the manual's doc_agent-primer, from karsa/agent/primer) on every turn, with the agent's persona and folder after it; the CLI works in the project's folder with the project's API in its env. An agent's subscribers set detach: true, so a turn shows in the chat as it runs: one status line (exec_… · thinking…, then each tool step, then finished), the exec linked, the reply streamed in.
Reinstall refreshes the app's records from its package and keeps its status, config, keys, ids and data. Uninstall stops it and deletes its folder (record, fns, subscribers, collections and their records) and its users; it lists exactly what goes first. Keys stay in Secrets, and the project's git history keeps a copy.
The worked example: the tunnel
karsa cloud link # join a Karsa Cloud account (once): type the code it shows
karsa host create # karsa/host/create (gateway: this server's, tunnel.karsa.dev)
karsa host up # karsa/host/up: the tunnel app enabled
karsa open # now opens the host's address (karsa open --local: this machine)
Enrollment asks Karsa Cloud to certify the host it minted when you joined. Cloud also issues the host's address (https://{uuid}.host.karsa.dev, in karsa host create's result and the cloud portal) and signs it into the cert, so the gateway serves exactly that address. The daemon persists the host as a host_ artifact; its key is derived from the server's root key, so no key file exists. Enrollment also installs the @karsa/remote app record (slug remote), disabled. Enabling it starts its service, remote_service, which runs karsa-tunnel-client (installed beside karsa-server by karsa install) with the keys in its env. Its fns are embedded because only the daemon can derive the keys. karsa host down stops it. Re-enrolling refreshes the same host (and picks up a re-issued address) without touching your enable/disable choice. The same process serves the server's ingress URLs: the daemon keeps a small file of their cloud certs, and the tunnel-client watches it, so creating, rotating or deleting an ingress needs no restart.
The undefined-second example: @karsa/caffeinate
The smallest service app: two one-line fns, no code at all. @karsa/caffeinate keeps a Mac awake so Karsa's schedules run (macOS only):
# app/caffeinate/index.md
setup:
check: caffeinate_check
service: caffeinate_service
# fn/caffeinate_check/index.md
runtime: cli
run: ["test", "-x", "/usr/bin/caffeinate"]
# fn/caffeinate_service/index.md
runtime: cli
run: ["caffeinate", "-dims"]
karsa install # lands the package seed in the $KARSA_HOME/packages download cache
karsa/package/install {"package": "@karsa/caffeinate"} # copies out of the cache into the System project, disabled
The install op reads the landed seed, mints the real app_ id (seeds may use vanity ids — they are rewritten), writes the record and its stamped fns, and mints the app's service user. Then enable it from the Apps page.
Package scope — server or project
Every package declares a scope in its karsa.package.json: server for a machine-level capability, project for a per-project one. karsa/package/install enforces it: a server-scoped package (caffeinate, the tunnel app — anything that manages the machine) installs only into the System project, so "one per server" falls out of "the System project is one per server"; a project-scoped package installs into the project you name with the project arg. Templates are project-scoped by nature. Omitted scope means server — the pre-scope packages were all machine-level.
Package tags — what it's for
A package can say what it's for, so people find it: "tags": ["reply-chat"] in its karsa.package.json. The Apps page is a phone's home screen (spec_karsa.package.jsonMundefinedYundefinedGundefinedWNTkarsa.package.json decision undefined): Yours (your apps as tiles), Get apps (the shop) and System. Tags decide where a package shows:
reply-chat: an agent harness whose agents answer in chat (Claude Code, Codex, OpenCode carry it). It is Get apps' Agents category (?view=get&cat=reply-chat), where a chat in a project with no agent links, so put it on a harness of your own.system: an app Karsa itself runs on (the Updater, Remote access, Keep awake, the Browser). It shows only under System, never among Yours or in Get apps.featured: first in Get apps, as a card.- Any other tag is a category chip in Get apps, in words (
invoicing→ "Invoicing").
Its icon and its line
"icon": "icon.svg"inkarsa.package.jsonnames a square image beside the manifest: an SVG, or a PNG of at least undefinedpx. The release ships it, Get apps shows it, and install copies it into the app's folder, where the record saysicon: icon.svg. Draw it full-bleed (the tile rounds the corners), with no text, and with no other company's logo."summary": what the app does, in one line for people ("Keeps your Mac awake so schedules run on time."). Get apps and the app's menu show it.descriptionstays the long one, for builders and agents.- An app made by hand or by an agent (no package): write a square
icon.svginto its folder (karsa/app/<slug>/file/write {"path": "icon.svg", "content": "<svg …>"}), then name it withkarsa/app/update {"id": "<app>", "patch": {"icon": "icon.svg"}}. Any image in its folder will do (.svg,.png,.jpg,.webp);"icon": nullgoes back to the letter tile. A reinstall keeps the one you named. - No icon: the tile shows the name's first letter, on a colour picked from the name.
Minting your own app
karsa/app/create. A page-only app: karsa/app/create ("The smallest app", above). The steps below are for an app with fns, a service or subscribers. src/app/<slug>/index.md. Install it as a package — a seed tree src/app/<slug>/index.md + karsa.package.json via karsa/package/install (that's exactly what @karsa/caffeinate is): the installer mints the record's app_<ULID> id and writes the id-named dir app/app_<ULID>_<slug>/index.md for you (status: disabled, your config, your secret names, a README body; the seed's _slug becomes the handle). Hand-authoring the dir works too, but you must id-name it — a bare app/<slug>/ is rejected by the validator. karsa.package.json. Write its fns and name them in the record: setup.check and setup.install if it needs anything outside Karsa, service if something must keep running, subscribers for events, ui for a page. karsa/package/install. Stamp every part you install — the fns, subscribers, any collection or agent user — with app: app_<id> in its frontmatter, and name your public surface in the record's exports. The stamp is what makes them one unit (show, upgrade, remove); it also makes them read-only to casual edits. app_<ULID>. Put secret values in the secret store — only their names go in the record. app/app_<ULID>_<slug>/index.md. Enable it with karsa/app/update or the Apps page. The events, the exec_ contract, and the app page are already yours.
Gotchas & sharp edges (learned building @karsa-app/codex)
Building the first real caged app surfaced these. Some are fixed; the ones marked TODO are known non-idiomatic edges to smooth — write your app to them, and expect them to get friendlier.
- A permission IS an op (spec_
permissions: { docdb: [chat, msg] }MpermissionsGPXpermission/chat/*RDApermission/msg/*§*/*). A fn declares thepermissionsit holds:permissions: { docdb: [chat, msg] }→permission/chat/*,permission/msg/*(a flat slug list works too). It names ops (resource/verb,resource/*):fullor*/*is never a fn's, and an app's fn holds only what its app was granted at install (what its fns named then). A heldpermission/msg/createmay carryattrsthat must equal the op's input params — that's how row-level scoping works ({ ownerId: user_X }= only your own rows). The owner holdspermission/fullin every project (everything in it; the server's own ops go by the System project). - A fn that spawns a subprocess declares
allow: [run]. Fixed: the launcher now grants the OS perms the fn declares (allow: [run, net, ffi, sys]) on any launch path — so spawning no longer forces "detached." (Older daemons hardcoded--allow-runon the detached path only, so aDeno.Commandfn had to bedetach: trueor it died with a bare permission error.) msg(and othersource: nullcollections) take{ frontmatter: { … } }.karsa/msg/create { frontmatter: { chat_id, body, author } }, not top-level{ chat_id, body }. The bare form errors with "requires chat_id and body" even though you passed them.- **A failed detached fn used to log nothing — the exec log was empty despite "open the exec log for detail." Fixed:** the failure reason now lands in the exec log. If you're on an older daemon and a detached run just says
failed, that's why. - Reinstall used to DUPLICATE an app's records (it minted fresh ids each time). Fixed by the cage + reconcile-in-place: same slug → same id, state kept. If you reinstall to iterate, your fn keeps its id now.
- A newly installed collection's CRUD can be
unknown tooluntil the daemon restarts —package/installdoesn't yet rebuild the per-project op catalog, so an app that writes to a collection it just installed (e.g.codex-context) may find the op missing. TODO (per-cage catalog, spec_package/installMundefinedXREXundefinedT §undefined.undefined): the cage's op catalog rebuilds with the cage. Until then, a fn's.catcharound its own-collection writes keeps the turn alive. - A fn writes as the user it runs as — there is no write-as. Decided:
msg/create { chat_id, body }(noauthor);_created_by= the user it runs as (the app's own, or the agent a subscriber names), shown via its display name. (Earlier drafts had the runner passauthor: user_agent— impersonation — which is retired.) - Addressing inside the cage is
project/_/…for project resources. A barekarsa/msg/createfrom inside an app resolves in the cage and (correctly) fails — reach the project chat withkarsa/project/_/msg/create { chat_id }. (Landing with spec_karsa/msg/createMundefinedXREXundefinedT §undefined.) - A subscriber needs its
project_id(and, for an app, its cage context). A subscriber that ships withoutproject_idis skipped as "malformed" at load — install must stamp it. (TODO: stamp the install-target project on caged subscribers.)
Where to go next
Read Anti-patterns next — it names the exact wrong turns (building outside Karsa, reaching for Supabase or another external service) and why each one quietly costs you the cage and the expand path.