How Karsa works
A few ideas explain almost everything in Karsa. Hold these and the rest follows.
Artifacts
Any piece of data a project can reach is an artifact. An artifact can be an arbitrary file, but most are structured (rows in a SQL DB) or semi-structured (documents — markdown + frontmatter, JSON). Structured data follows a schema — its type.
Collections and resources
Artifacts of the same type are grouped into a collection, and an artifact inside a collection is a resource. A collection is the usual way you see and work with data in Karsa: it holds the configuration for where its resources live (a SQL table, a documents folder, …) and it guarantees every resource in it shares the same schema.
Collections are defined per project — what collections a project has, and how each renders, lives in the project's own files, not baked into Karsa.
Every resource looks the same
Whatever the data source, every resource carries the same baseline fields:
id— its identifier;_t— its type;_created_at/_created_byand_updated_at/_updated_by— when, and by whom. (The_prefix marks a server-owned field: the server stamps these, a write never sets them.)
"By whom" is an actor: a person, an agent (a user of kind: agent, doc_agents), or a fn. That small fact is what lets an agent safely react to changes without reacting to its own changes — the basis of a stable agent loop.
An id reads {type}_{unique} — e.g. spec_01KVSRJ0RG2G. Some resources also have a slug for readability, and you'll see the {id}_{slug} form in URLs and links — but Karsa resolves links by the id alone, so a slug going stale never breaks anything.
Three file names, one rule
On disk, three well-known filenames divide the world:
index.md— the directory is a Karsa resource: the file carries machine-read YAML frontmatter (the record) with prose below. Machines and humans both read it; edits are data edits.README.md— plain-filesystem orientation for whoever is browsing (human or agent). Never machine-read; pure documentation.KARSA.md— exactly one, at the top of$KARSA_HOME: the home's own record (machine-wide config in frontmatter, your notes in the body).
So: resource → index.md; orientation → README.md; the home itself → KARSA.md.
Naming: collections are SINGULAR
A collection directory is always the singular noun. key/, app/, task/, spec/, host/, user/ — never keys/, apps/, tasks/.
Why: the directory names the type of one thing in it, the same way a type is singular. It also makes the id prefix, the type name, and the path agree — task/ holds task_01H… resources of type task, and the API path is karsa/task/create. A plural directory breaks that chain for no gain.
The plural exists only as a display label, in {collection}/index.md:
name: "Task" # singular — what one is
plural: "Tasks" # collection label, for headings and nav
The same rule holds for anything you add: a new collection, a template, a package's seeded collections. Singular directory, plural label.
Directory structure
A project is a directory. Everything about it is a file in it.
{project_home}/karsa/{project_id}/
index.md the project's own record (frontmatter + prose)
artifact/ every collection lives here
task/
index.md the COLLECTION's record: type, schema, UI config
task_01H….md one resource
spec/
index.md
spec_01H….md
chat/
index.md
chat_01H…/ a resource may be a DIRECTORY when it owns files
index.md
.tmp/ purgeable — wiped on daemon restart, never durable
Collections are singular, so a webhook entrypoint lives in ingress/ and a tunnel identity in host/. Both are server-wide records in the System project: a host is a public entrypoint for people (an address the one tunnel connection serves), and an ingress record notes which project a Karsa Cloud webhook URL feeds. The URL itself belongs to Karsa Cloud, with its own id, so rotating or deleting a webhook never touches a host.
There is no hidden state directory. Everything durable is an artifact you can read, and the only secret on the machine — the server's root key — lives in the OS keychain (macOS Keychain, Linux Secret Service, else a undefined file outside the project). Every other key is derived from it on demand, so no key file exists to back up, export, or accidentally commit.
Two rules keep this predictable:
- A resource is either
{id}.mdor{id}/index.md. Use the directory form when the resource owns files of its own; both are the same resource to the API. - Anything under
.tmp/is disposable. It is wiped when the daemon restarts, so nothing that matters may live there. Scratch sits inside the directory it serves, so deleting an artifact takes its scratch with it.
What shows up in the sidebar (opt-in)
A collection appears in the sidebar only if it asks to. In {collection}/index.md:
ui:
group: "artifacts" # main: at the top; anything else: under Advanced; none: not in the sidebar
order: 20 # position within that group (optional)
renderer: document # how records are displayed: document | code | log | table
app_bar: compact # optional: on a phone, its record pages hide a bottom app bar (a chat: the message box carries the menu); default regular
- The top of the sidebar is five places, with no heading: Home, Chat, Tasks, Apps, Files. They are there whatever their
ui.groupsays. ui.group: mainputs a collection at the top, beside them, inui.order(Home undefined, Chat undefined, Tasks undefined, Apps undefined, Files undefined). Keep it for what people use every day (an Invoices list in a finance project).- **Any other
ui.groupputs it under Advanced**, one group below the five, closed until opened (it stays open or closed on each device).operate,artifactsandadvancedare just "not main"; a row you're on shows even when Advanced is closed. - No
ui.group→ not in the sidebar. That is not an error and nothing is lost: the collection is fully reachable under Browse, and its API works exactly the same. - This is deliberate. A project accumulates directories, and a sidebar that lists every one of them stops being navigable. Showing something is a decision someone makes.
- To surface one, set
ui.group— or just ask your agent to ("put the invoices collection at the top of the sidebar"). It is a one-line frontmatter edit. - The project record's
ui.navinkarsa/{project_id}/index.mdrenames, reorders, hides and adds rows; itsgroups(labels and order) apply inside Advanced.
Where the logs are
Three layers, each answering a different question:
| layer | where | answers | |---|---|---| | daemon log | $KARSA_HOME/log/server.log | Did the daemon boot? Raw process output, machine-wide, a plain file — it exists before any project does. | | exec | {project}/artifact/exec/, one per run | What did that run print? Agent output, and every line a supervised app writes. | | chat | {project}/artifact/chat/ | The conversation itself — nothing else. |
Every attempt to run something produces an exec, including one that never started — a failed start records its reason in that exec's log, so there is always somewhere to look.
There is no separate "app log" or "fn log". A supervised app is a long-running exec — the tunnel-client's output is its exec log. The app record's fn names the controller that converges it between enabled and disabled; the supervised process itself is not a fn.
The daemon's log is a plain file, so the log collection in the Karsa System project makes it reachable: each record names a file on disk, and karsa/log/read pages through it with the same cursor reader an exec log uses. The seeded server entry is $KARSA_HOME/log/server.log — that is how you read it in the web UI or ask an agent for it, instead of tailing a terminal.
CRUD and events — the same for everything
Karsa gives every configured collection a uniform CRUD API — create / read / update / delete — that works out of the box for SQL- and document-backed collections (and takes a custom handler when a source needs one). Every write automatically emits an event: {collection}.created, {collection}.updated, {collection}.deleted.
All events flow onto one queue. You can filter that queue and subscribe to it; when a matching event fires, Karsa notifies your subscriber.
Subscribers — where work happens
A subscriber binds a trigger to a fn: when this trigger fires, run that fn. The trigger is either an event match (e.g. msg.created where author_kind: human) or a time (at / every / daily at a wall-clock time in a time zone) — an event-driven subscriber and a scheduled job are the same object, just a different trigger. There is no separate "event handler" or "scheduler" object to create. A fn is the unit of logic that runs: plain code, a built-in, or an agent harness's runner. An agent is a user (kind: agent) whose record's body is its persona and whose wake names the runner; its own two subscribers (a chat mention, a task handed to it) run it, so who an agent is and how it works is data you edit, not code. (See Agents and Automating with events, subscribers, and fns.)
Apps — many parts, one unit
A capability that installs several parts at once — fns, subscribers, a collection — is packaged as an app. The parts stay ordinary artifacts in their native collections, each stamped app: back to one app record (the anchor, never a container). 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. (See Building a Karsa app.)
Putting it together
Typed collections + a uniform CRUD surface + one event queue + subscribers that bind triggers to fns is enough to stand up a long-running agent doing real work: it reacts to data changes and to the clock, writes typed resources back, and those writes are themselves events that drive the next step. The chat loop you use day-to-day (see Using Karsa) is the first worked example of exactly this.
(Coming: the per-project Karsa app — each project as a self-contained, caged app over the Karsa SDK, not just data + subscribers.)