← The Karsa Manual

Automating with events, subscribers, and fns

Read this when the user wants a system that keeps running — a pipeline, a watcher, a nightly job — not a one-shot answer. Don't reach for cron files, a background daemon, or an outside queue. Karsa gives you three primitives: events, subscribers, and fns. Build on them, through $KARSA_API_BASE.

The model in one breath

So a pipeline is: something happens → an event fires (or a timer comes due) → a subscriber runs its fn → the fn's writes fire the next events. That's the whole engine. (Read doc_concepts for the model, doc_data-and-storage for collections.)

> One shape for everything. An event-triggered subscriber and a scheduled job are the same thing — a subscriber whose trigger is an event vs a time. There is no separate "scheduler" object to create and no separate "handler" object: a subscriber points straight at a fn.

Publish an event

Any code (an fn, or the agent during its run) can put an event on the queue:

POST ${KARSA_API_BASE}/rpc
{ "method": "karsa/event/create",
  "params": { "type": "karsa/deal/enriched", "payload": { "deal_id": "deal_01…", "risk": "medium" } } }

Collection writes publish their CRUD events for you — you only publish custom events for signals that aren't a plain create/update/delete (e.g. karsa/deal/refresh_due, karsa/report/ready).

Create a subscriber (event-triggered)

Deals land in a deal collection; have an agent enrich each new one. The usual shape: the subscriber hands the agent a task (here with the no-code passthrough fn, below), and the task queue hands it over:

POST ${KARSA_API_BASE}/rpc
{ "method": "karsa/subscriber/create",
  "params": { "frontmatter": {
    "trigger": { "on": { "type": "karsa/deal/created" } },
    "fn": "fn_passthrough",
    "params": { "op": "karsa/task/create", "args": {
      "frontmatter": { "title": "Enrich {{event.payload.id}}", "status": "ready", "assignee_id": "<the agent's user id>", "chat_id": "<a chat to report in>" },
      "body": "A new deal was created: {{event.payload.id}}. Fetch its public filings, summarize the risk, and update the deal via karsa/deal/update." } },
    "run_mode": "running"
  } } }

> Agent work is a task. An agent is a user (doc_agents), not a fn: an automation hands it a task (assignee_id = the agent, chat_id = where it reports), and the queue wakes it with the task: serialized, retried, on the board, reported in the chat. For something immediate, run the agent's wake fn directly (doc_agents, Wake an agent from code).

Create a subscriber (time-triggered = the "schedule")

A scheduled job is just a subscriber with a time trigger — no separate schedule object:

POST ${KARSA_API_BASE}/rpc
{ "method": "karsa/subscriber/create",
  "params": { "frontmatter": {
    "trigger": { "every": "24h", "at": "2026-06-24T07:00:00Z" },
    "fn": "fn_passthrough",
    "params": { "op": "karsa/task/create", "args": {
      "frontmatter": { "title": "Refresh the deals", "status": "ready", "assignee_id": "<the agent's user id>", "chat_id": "<a chat to report in>" },
      "body": "Re-fetch each deal's latest data, update its row, and note anything material that changed." } },
    "run_mode": "running"
  } } }

A nightly job at undefined:undefined your time that still runs on a laptop that was closed at undefined:undefined:

"trigger": { "daily": "03:00", "tz": "Asia/Jakarta", "catch_up": "once" }

On a day the clocks spring forward past undefined:"3:00" (e.g. undefined:undefined doesn't exist), the job runs at the first moment after the jump; on a day they fall back and the time happens twice, it runs once, the first time. Karsa refuses a bad time ("3:00", "24:00") or an unknown zone with a message saying which.

To see when it runs: karsa/subscriber/run-status {id} answers a scheduled subscriber's run_mode, next_fire_at (null while it's paused, or once a one-off has run) and last_fired_at; karsa/subscriber/set-run-mode {id, mode} pauses it (paused), runs it on its schedule (running), or fires it once at its next time and pauses it (run_once). Its mode is its file's run_mode.

When the timer comes due, Karsa runs the subscriber's fn directly. (You can have the fn publish a custom event instead of doing the work inline — then a second subscriber reacts to it — but usually the fn just does the work.)

> A task for later? Don't hand-roll a wake. Give it a when ("status": "ready", "when": "<ISO with offset>"): the queue holds it until then and it shows on the calendar. The seeded task-defer controller makes the one-shot wake that runs the queue at that moment (cancel or re-aim by editing the task's when).

See and make them in the app

In a project, Advanced › Subscriber lists every subscriber, each app's among them. The buttons at the top show All, On an event or On a schedule: that last one is every scheduled job, recurring or one-off (a deferred task's wake among them), each with when it runs and when next. A scheduled subscriber's page shows Runs next and Last ran, and its paused, run_once and running buttons pause it, fire it once at its next time, or run it on its schedule.

+ New Subscriber asks When: On an event (its matchers) or On a schedule: Every (a number of minutes, hours or days, optionally from a date and time), Daily at (a time, in a time zone; blank is this machine's) or Once at (a date and time). For every and daily, If Karsa was off when it was due, run once when it starts is catch_up: once. A subscriber runs on one or the other, never both: to run a fn on both, make two.

No-code reactions: the passthrough fn

For a reaction that is just "when X happens, call one Karsa op with fields from the event", you don't need an agent or any code — bind the built-in fn_passthrough:

POST ${KARSA_API_BASE}/rpc
{ "method": "karsa/subscriber/create",
  "params": { "frontmatter": {
    "trigger": { "on": { "type": "karsa/deal/created" } },
    "fn": "fn_passthrough",
    "params": {
      "op": "karsa/task/create",
      "args": { "frontmatter": {
        "title": "Enrich {{event.payload.id}}",
        "references": ["{{event.payload.id}}"],
        "status": "ready"
      } } },
    "run_mode": "running"
  } } }

Use it for event → task fan-out (handing agents work), mirroring records between collections, or publishing a derived custom event. The moment you need branching or fetching, write a real fn instead (doc_agents has one that stores events in SQL and wakes an agent in batches).

Retries: declare a retry plan on the subscriber

Never hand-roll a retry loop or a sleep inside a fn. Declare the plan on the subscriber and Karsa wraps the fn:

"retry": { "max_attempts": 3, "backoff_ms": 30000, "factor": 2 }

On each fn failure Karsa re-runs the delivery on that ladder. When the ladder is used up, the event is dead-lettered: a log line is written and a karsa/subscriber/retry_exhausted event ({ subscriber_id, fn, event_type, event_id, attempts, cause }) goes onto the queue — subscribe to it if you want an escalation path (e.g. post into a chat). The subscriber then moves on to later events; a success anywhere on the ladder stops it.

Putting a pipeline together

A deal-flow pipeline is a handful of subscribers:

doc_data-and-storage. A deal collection (docdb or sqldb) — the records (doc_data-and-storage). karsa/deal/created. An event-triggered subscriber on karsa/deal/created — enrich each new deal. daily: "06:00". A time-triggered subscriber (daily: "06:00") — keep deals current. undefined. The fn does the fetching/summarizing during its run (an agent exec can use the network) and writes results back as resources — whose writes emit the next events.

Build each piece through $KARSA_API_BASE, put the standing instruction in each subscriber's params, and the system runs itself between your chats.

Where fns live (you rarely author the plumbing)

Anti-patterns