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
- Every write to a collection auto-emits an event —
karsa/{collection}/created//updated//deleted— onto Karsa's single event queue. You can also publish your own event. - A fn is a unit of logic (code, or an agent-CLI wrapper). It's what actually runs.
- A subscriber binds a trigger to a fn: when this trigger fires, run that fn. The trigger is either an event match or a time (
at/every/daily).
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).
trigger.on— the event filter:{ type, attrs }.typeis an event type (a{collection}.created/updated/deleted, or a custom one you published; a trailing*is a prefix, e.g.karsa/ext/*).attrsmatches fields on the event payload.fn— the fn to run (itsfn_…id): your own code, a built-in likefn_passthrough, or an agent'swake(which is how an agent's own notification subscribers work).params— static input handed to the fn on each fire. The event is also passed to the fn.user— who its runs are: each run authenticates with a session of its own (minted with the subscriber, ended when it's deleted), so what it writes is that user's and is checked against what they hold. The owner may name anyone (or no one: it runs as its fn, with only what the fn declares; a passthrough runs as you then); anyone else's subscriber runs as themselves.permissionsnarrows its runs further (spec_permissionsMkarsa/subscriber/updateNuserKWSparamsGX). Its session is for what it runs: change itsfnorparamsthrough Karsa (karsa/subscriber/update), since one changed in its file stops running until it's changed through Karsa again._updated_by: { "$ne": "$self" }— the loop-safety filter: it stops a subscriber waking on what its own runs wrote ($selfis the subscriber, its fn, and the user it runs as). Use it whenever the fn writes into a collection it also listens to (doc_concepts).title— optional display title. Omit it and Karsa composes one from the binding ({trigger} > {fn name}), so lists stay readable either way.- Never supply an
id— resource ids are server-minted; the create returns the mintedsber_…. run_mode—running(live) orpaused.
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"
} } }
trigger.at— first (or only) fire time, ISO-undefined. Omiteveryanddailyfor a one-off (fires once, then done; if Karsa was off at that time, it fires when Karsa starts).trigger.every— makes it recurring: an interval like30m/1h/24h/7d(cron expressions later).atis the anchor; a job that fell behind catches up to the next future slot without replaying missed fires.trigger.daily— every day at a wall-clock time,"HH:MM"(undefined-hour, quoted), intrigger.tz: an IANA zone like"Asia/Jakarta"; leavetzout to use the zone of the machine Karsa runs on."Asia/Jakarta":trigger.dailystays"Asia/Jakarta":trigger.dailywhen daylight saving starts or ends. Withat, no fire before it.dailyandeverycan't be combined.trigger.catch_up: once— witheveryordaily: if Karsa (or the machine) was off at a slot, the job runs once when Karsa starts, however many slots it missed. Without it, missed slots are skipped. (A slot the machine slept through always runs once on waking.)
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"
} } }
params.op— anykarsa/{resource}/{verb}operation (and onlykarsa/*— nothing else is dispatchable).params.args— the op's argument template. String values may reference the triggering event with{{event.payload.x}}(or{{event.event_type}}): a string that is exactly one{{…}}keeps the resolved value's raw type; embedded references interpolate into the string. An unresolvable path fails the fire loudly (pair with aretryplan below if the payload can be late-shaped).- It calls the op as the subscriber's
user(its session), so its writes are theirs and it can do only what they may: a passthrough subscriber always runs as someone (you, when you make one naming no one).
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 }
max_attempts— total attempts including the first (1= no retry).backoff_ms— delay before the first retry (default1000).factor— multiplier per further retry (default2). So the plan above runs, then retries after undefineds, then after undefineds.
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)
- The task queue (slug
task_queue), task progress / defer / wake, the core built-ins (passthrough, indexing, retry, the scheduler-sync) and each harness app's runner (an agent'swake) already exist: reference them; don't rewrite them. - Your own logic is a fn:
karsa/fn/create {slug, code, permissions}(what it may call:{docdb: [collections], ops: [op ids]}; none = no op calls), changed later withkarsa/fn/update(doc_building-a-karsa-appfor the SDK). But for most automation you point a subscriber at the passthrough fn and hand an agent a task, no new code.
Anti-patterns
- Don't write cron files, spawn your own daemon, or sleep-loop. Use a time-triggered subscriber.
- Don't hand-roll retries or backoff inside your fn. Declare a
retryplan on the subscriber and Karsa wraps the fn (see "Retries" above). - Don't poll a collection on a timer to find changes. Subscribe to its
.created/.updatedevents. - Don't forget the
_updated_by: { "$ne": "$self" }loop-safety filter when a fn writes into a collection it also watches.