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).
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}/event
{ "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; enrich each new one. Create a subscriber whose trigger matches karsa/deal/created and whose fn is your agent wrapper:
POST ${KARSA_API_BASE}/subscriber
{
"frontmatter": {
"trigger": { "on": { "type": "karsa/deal/created", "attrs": { "updated_by": { "$ne": "$self" } } } },
"fn": "<the run-agent fn id>",
"params": { "agent": "<the planner user id>", "instruction": "A new deal was created (see the event). Fetch its public filings, summarize the risk, and PATCH the summary back onto the deal via the API." },
"run_mode": "running"
}
}
> Which fn for agent work? Bind the project's run-agent fn (Productive seeds it — slug run_agent; your instructions carry its minted id). It runs an agent USER (params.agent — the record's body is its instructions, its fns: list the harness preference, first installed wins), passes your params.instruction for this fire, and — with params.chat_id — streams the output into that chat authored as the agent. Don't bind a raw harness fn (claude_code/codex) from a subscriber: it won't pick a fallback or detach. > > Report vs work. Bind the PLANNER as the agent and let it decide each fire: reports it answers into the chat; work it turns into a ready task the worker executes — serialized, retried, on the board. The task system stays the single executor for work; a timer is just one more producer feeding it.
trigger.on— the event filter:{ type, attrs }.typeis an event type (a{collection}.created/updated/deleted, or a custom one you published).attrsmatches fields on the event payload.fn— the fn to run. An agent is just a fn (an agent-CLI wrapper likefn_claude_code); a pure-code reaction is a fn too. "Which agent / how it's configured" lives in the fn (or itsparams) — not on the subscriber.params— static input handed to the fn on each fire (e.g. the agent's standing instruction). The event is also passed to the fn.updated_by: { "$ne": "$self" }— the loop-safety filter: it stops a subscriber waking on its own writes. Use it whenever the fn writes into a collection it also listens to (doc_concepts).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}/subscriber
{
"frontmatter": {
"trigger": { "every": "24h", "at": "2026-06-24T07:00:00Z" },
"fn": "<the run-agent fn id>",
"params": { "agent": "<the planner user id>", "instruction": "Re-fetch each deal’s latest data, update its row, and note anything material that changed.", "chat_id": "<optional: chat to deliver into>" },
"run_mode": "running"
}
}
trigger.at— first (or only) fire time, ISO-undefined. Omiteveryfor a one-off (fires once, then done).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.
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.)
> Deferring a task? Don't hand-roll a wake. In Productive, set the task to status: "blocked" + wake_at: "<ISO>" — the seeded task-defer controller creates the one-shot wake for you and flips the task to ready at that moment (cancel or re-aim by editing the task).
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. every: 24h. A time-triggered subscriber (every: 24h) — 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 run-agent fn (slug
run_agent), the harness fns it drives (claude_code,codex), and the core built-ins (indexing, retry, the scheduler-sync) already exist — reference them; don't rewrite them. - Your own logic is a fn you write into the project's
fn/collection (doc_building-a-karsa-appfor the SDK). But for most automation you just point a subscriber at the run-agent fn (agent = the planner) and describe the work inparams.instruction— 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 (doc_concepts→ retry). - 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.