← The Karsa Manual

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:

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

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:

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

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