The Karsa API
You don't have to memorize a big endpoint list. Karsa's API is one JSON-RPC transport, uniform and generated: the moment you expose a collection, it gets full CRUD operations, a schema, and CRUD events — automatically. Every call is the same shape:
POST ${KARSA_API_BASE}/rpc
{ "method": "karsa/{resource}/{verb}", "params": { … } }
→ { "result": … } on success
→ { "error": { "code", "message } } on failure
$KARSA_API_BASE is already project-scoped — never hard-code a host or /api/.... When you're an agent, it also carries your token, so every call you make through it is you: keep using it as is.
Who a call is: sessions and tokens
Every call is someone, checked against what they hold (spec_undefinedMundefinedNundefinedKWSundefinedGX):
- A token is its user:
Authorization: Bearer <token>, or in the path (/api/t/<token>/project/<id>/…, which is what an agent's$KARSA_API_BASEis). With a token, a call is that user, never more: a claimedx-karsa-actoris ignored, and a token that's revoked or expired is refused (401). - A token can be narrowed: it may do what its user may do AND what its own list allows.
- Make, list and end tokens:
karsa/session/create {"user"?, "permissions"?, "ttl_s"?, "label"?}(the token is shown once),karsa/session/list,karsa/session/revoke {"id"}(the session'ssid). The owner may make one for anyone; anyone else only for themselves, and never wider than they hold. - Every call needs a token, from this machine too: without one it's refused (
401), exceptGET /healthand the manual ($KARSA_DOC_BASE). The one exception is the owner socket,$KARSA_HOME/.run/karsa.sock, which only the owner's OS user can open: a call over it is the owner (that's how thekarsaCLI talks). Who the CLI is in each case (you, an agent in its run, anyone else): Using Karsa → Who thekarsaCLI is.karsa open(karsa/session/open, the owner only) opens your server's address when it's linked to Karsa Cloud with the tunnel on, and otherwise a one-time link that signs a browser on this machine in (karsa open --local, orlocal: true, always the link); its answer'sviasays which (hostorlocal). Linked, a browser on this machine can also sign in with that account. See Signing in in the user guide. An agent's$KARSA_API_BASEcarries its token.
Params are checked before anything runs. An operation that declares an input schema (the same schema MCP tools/list shows) refuses unknown fields, wrong types and over-long values with code: "invalid" and data.errors: [{path, message}], so fix the named field and retry. Collection writes are checked against the collection's schema the same way.
The uniform CRUD pattern
For any collection {c} (its slug — spec, task, deal, …), five operations exist automatically:
karsa/{c}/list params: optional { q } → array of resources
karsa/{c}/get params: { id } → one resource
karsa/{c}/create params: { frontmatter, body? } → the created resource
karsa/{c}/update params: { id, patch, body? } → the updated resource
karsa/{c}/delete params: { id } → { ok }
- Create:
{ "method": "karsa/deal/create", "params": { "frontmatter": { …your fields… }, "body": "markdown…" } }. The server mints theidand stamps_t/_created_at/_created_by/_updated_at/_updated_by— don't send those. - Every write automatically emits an event:
karsa/{c}/created/karsa/{c}/updated/karsa/{c}/deleted— which a subscriber can react to. You get eventing for free.
Filter, sort and limit any list: q
Every list takes q, a small SQL-ish query over the resource's fields (its schema: karsa/type/get):
karsa/exec/list {"q": "status = 'failed' and started_at > -1d order by started_at desc limit 20"}
karsa/task/list {"q": "title contains 'invoice' and status in ('open', 'blocked')"}
karsa/exec/list {"q": "fn_name = 'fetch_rates' and input.base = 'USD'"}
[where] <condition> [order by <field> [asc|desc], …] [limit <n>]. Conditions: =, !=, <, <=, >, >=, contains (case-insensitive), like 'pre%', in (…), is [not] null, with and, or, not and parentheses. Values: 'quoted' strings (or bare words), numbers, true/false/null, and times relative to now: -15m, -1h, -7d, -2w. Dotted fields reach inside objects (input.base). A query that doesn't parse is refused (invalid) with where it went wrong. The run list (exec) reads newest first and stops once it has enough matches, so filtering a long history stays fast.
Every resource has the same envelope
Whatever the collection, a read returns these baseline fields (plus the type's own fields):
| field | meaning | |---|---| | id | the resource id ({type}_{ULID}) | | _t | its type | | _created_at / _created_by | when + who created it | | _updated_at / _updated_by | when + who last changed it |
The four provenance fields are _-prefixed because they're server-owned — the server derives them and strips any you send. _created_by/_updated_by is the acting user (user_…, e.g. an agent user) or user for a direct write — a fn's write carries the fn's id (or the user it runs as). That's what a subscriber's _updated_by: { $ne: "$self" } filter keys on: $self is the subscriber, its fn, and the user it runs as (see Automation).
Types, schemas, and CRUD shapes
A collection enforces a type, and a type has a schema — so the server knows every CRUD shape derived from it. Inspect them:
karsa/type/list → all types in this project
karsa/type/get { id: type } → one type: its descriptor, base JSON Schema,
and the derived variants:
variants.read — the full resource (base + envelope)
variants.create — the create input (base minus the server-managed fields)
variants.update — the update patch (any subset)
variants.events — karsa/{c}/created / /updated / /deleted payloads
Expose a new type (a type/<name>/schema.json) or a collection over it, and its CRUD API + schema + events are immediately live — nothing else to wire.
Beyond plain CRUD
Still the same rpc transport, different methods:
karsa/collection/create— expose a new collection (e.g. over a SQL table; see Data and storage).karsa/subscriber/create— a standing rule: event-triggered (trigger.on) or time-triggered (trigger: { at | every | daily },daily: "HH:MM"with an IANAtz,catch_up: onceto run a missed slot when Karsa starts — the scheduled-job form; no separate schedule object). See Automation.karsa/event/create— publish a custom event:params: { type, payload }.karsa/exec/get— an agent run.karsa/share/*— share this project with a Karsa Cloud account (doc_using-karsa, Sharing a project):collections(what it can share),list, and the owner'screate {email, note?, collections: [{collection, access: read|write}]},update,delete. A share's own calls come in as its person;karsa/share/describetells it what it was given.karsa/mount/*— what's shared with you, or another of your projects, in this one:sources,describe {source}(in a project: each collection'sname_here, and whether it'smounted),projects {source},create {source, collections?: [id | {id, as}]}(the owner's; asourceis a share,shr_…, or another of your projects),list,delete. Every op on a mount runs at its source; it fails withunavailable(undefined, its server offline) orended(undefined, the share is over).karsa/event/feed {since?, wait?, limit?}— this project's events after a cursor, waiting up towaitseconds for new ones (what a mount follows).
The exceptions — not JSON-RPC by nature (the only non-rpc endpoints):
POST ${KARSA_API_BASE}/file(multipart, fieldfile) — file upload is binary; baseundefined-through-JSON would bloat it.GET …/exec/{id}/log— live log streaming.GET /health, the web UI,/doc— infra, not data ops.
Secrets — declare, ask, use (never handle the value)
When you need a credential (an API key, a token), you never ask for the value in chat and never put it in a file or env var. Instead:
For a CLI instead of HTTP: . Declare a secret with a Sentence-case title, a description, and a usage policy that scopes where it may go: `` POST ${KARSA_API_BASE}/rpc { "method": "karsa/secret/create", "params": { "frontmatter": { "title": "Resend API key", "description": "Send deal-flow notification emails.", "policy": { "via": "http", "allow": [ { "host": "api.resend.com" } ] } } } } → { "result": { "id": "secret_…" } } ` For a CLI instead of HTTP: "policy": { "via": "tool", "tools": ["gh"], "inject": { "env": "GH_TOKEN" } }. 2. **Ask the user** to open the secret's link and paste the value — e.g. *"I need the Resend API key — please add it here: secret_…"*. They fill it through a masked field; it's encrypted and you're notified (a karsa/secret/updated event, status set). You never see the value. 3. **Use** it by reference — the daemon substitutes it and makes the call; the value never reaches you: ` POST ${KARSA_API_BASE}/rpc { "method": "karsa/egress/create", "params": { "method": "POST", "url": "https://api.resend.com/emails", "headers": { "Authorization": "Bearer {{secret_…}}" }, "body": { … } } } ` or for a tool: { "method": "karsa/tool-run/create", "params": { "tool": "gh", "args": [...], "use": ["secret_…"] } }`.
The daemon refuses to send a secret anywhere its policy doesn't allow (so a key bound to api.resend.com can't be redirected elsewhere). karsa/secret/list lists secrets (names + has_value, never the value). Do not ask the user to paste a key into chat, and don't try to read a secret's value — there is no operation that returns it.
Discover the whole surface
The complete, machine-readable contract is generated live — including the per-collection CRUD for this project's collections and every type's variant schemas:
GET ${KARSA_API_BASE}/openapi.json
That's the source of truth if you need an exact request/response shape (it catalogs the rpc operations). But for everyday work: the uniform CRUD pattern over the one rpc transport is the whole game.