← The Karsa Manual

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.

`` 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."

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

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:

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:

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

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]

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:

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" }

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:

| 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

"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:

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)

Chat routing and agents (spec_undefinedMundefinedEundefinedKundefinedQundefinedC, spec_undefinedMundefinedJFHundefinedWCKV)

Routing is purely declarative: no dispatcher, no special "default" subscriber.

The same primitives compose beyond AI replies:

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.

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:

Its icon and its line

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.

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.