Agents
An agent works for you in a project: it answers in chats and works the tasks handed to it. It's a project user (kind: agent), like you, so it acts under its own name and its own permissions. It runs on an agent harness: an app that drives an AI coding CLI, OpenCode (open source), Claude Code or Codex. OpenCode on Mango Inference and Claude Code on Mango Inference each include their harness and set it up for Mango's models, with your Mango key.
In a nutshell
| Part | What it is | |---|---| | The agent | A user with kind: agent. Its folder user/<id>_<handle>/ holds index.md (its persona: who it is and how it works, in the body), docs/ (reference only it reads) and context/ (its own notes and memory). | | wake | The fn that runs it: a harness instance's runner (opencode_runner, claude_code_runner, codex_runner). Change it to move the agent to another harness, or to another copy of one on another model. | | Its notifications | Two subscribers Karsa makes with it: chat (a person's message that mentions it) and task (a task the queue hands it). Both run its wake. | | Defaults | The chat's default responder answers a message that mentions no one. The task collection's default worker takes a task with no assignee. |
An agent that keeps working between your messages, on a clock, on webhooks and on your data, is an always-on agent: doc_always-on-agents builds one step by step.
Every time an agent wakes it's told what Karsa is, which project it's in, how to use the project's API and this manual (the agent primer, doc_agent-primer), then its persona and where its folder is. It works in the project's folder.
Each agent is its own OS user. Every process Karsa starts for an agent (its turns, the harness CLI, any fn it runs) runs as a hidden OS user of its own, _karsa_<its id>: it can't read your private files (~/.ssh, your logins, Karsa's keys), your keychain, or another agent's folder. It reads and writes its project; its own folder is its and yours only; its .home/ holds its harness's logins and caches. Making those users takes root once, and Karsa never asks for your password: you read and run one short script, sudo ~/.local/bin/karsa-enforce-security, and karsa start won't start until you have. If it goes missing later, agents and apps don't run at all (never as you instead): karsa status, Settings, the You menu and each refused turn say so. Your home and Karsa's become yours only, and in the folders an agent passes through to reach its project, everything else is closed to it. Beyond its project, it reaches only its file-access list (Auto, the default: your desktop, documents and downloads folders, to read); when it's blocked, it asks you in its chat, Once, Always or Never. You can also ask it to set this up ("give yourself my Code folder"): it asks for them in one card (doc_using-karsa, What each agent can reach). What it can still do: reach the internet, and read files anyone on the machine can read outside your home. So:
- Give an agent work you'd be fine having done on this machine: it has its project.
- Keep keys in Karsa's secrets, not in files or messages: a fn gets one by name and never sees where it lives.
- Watch what it reads from outside (webhooks, web pages, files someone else wrote): text there can try to steer it. Every agent is told to treat such text as data, to keep your secrets out of what it writes, and to stay inside its project (
doc_agent-primer).
Your first agent
reply-chat. Apps (sidebar) → Get apps → Install OpenCode on Mango Inference (@karsa/opencode-mango; or OpenCode on your own config, Claude Code, Claude Code on Mango, or Codex). The apps whose agents answer in chat carry the tag reply-chat: they are Get apps' Agents category (/project/<p>/app?view=get&cat=reply-chat), where a chat in a project with no agent links. /project/<p>/app?view=get&cat=reply-chat. Open it. Overview: Includes shows the OpenCode inside it and whether its CLI is on this machine (Install installs the version it pins); Keys: pick which of your secrets is its Mango Inference API key, or New key… to add one. Picking it is your yes: the app's fns, and the OpenCode it includes, get its value. From a terminal: karsa secret create --title "Mango — personal" --value (it asks for the value without echo), then karsa app set-key opencode-mango --env MANGO_INFERENCE_API_KEY --secret <its id>. karsa secret create --title "Mango — personal" --value. Config: the model, from Mango's live list: one model, or a line's newest (GLM, latest is zai-org/GLM-*, the default; it moves to a newer GLM by itself). karsa app set-key opencode-mango --env MANGO_INFERENCE_API_KEY --secret <its id>. Enable it.
Installing it made a starter agent, @opencode-mango, with the general persona. When the project had no default responder or default worker, it's both: it answers messages that mention no one, and it takes tasks with no assignee. Its page lists it under Agents.
Talk to agents in a chat
- A message that mentions no one goes to the chat's default agent: the chat's own (
karsa/chat/update {id, patch: {default_ai_reply_user_id: "user_…"}}), else the project's (the chat collection'sconfig.default_ai_reply_user_id). A chat has one default agent. - @mention agents to bring them in:
@opencode @reviewer look at the invoice app. Each one mentioned works in the chat. A message that mentions anyone doesn't go to the default. - The chat is the glue for a piece of work: its messages, and the tasks it started (they carry its
chat_id, and post a status line here each time they move).
While an agent works, the chat shows one status line, exec_… · thinking…, updated with each step, then finished (or failed); the exec id opens the run and its log. A person's message that reaches no agent gets a line saying how to reach one.
- Who replies, a pill above the chat box: "OpenCode on Mango · GLM-undefined.undefined ▾", beside "On <page>" (what the chat is about). Tap it to pick this chat's agent (or make one the project's default) and, when the agent's app offers it, the model.
karsa/chat/replier {id}says the same for an agent: the chat's replier,from: chat | project, its app and the app's quick fields. - Thinking and the reply. What a harness agent says while its turn runs is its thinking (
kind: thinkingon the message): the chat folds it into one line, the latest step, with Show thinking (N). When the turn ends, its last step becomes the reply, a normal message. Only the reply notifies the owner, once per turn; a step that @mentions a person (a question) notifies at once. You read all of it back inkarsa/chat/get. - A model's error is said in the chat. If the model refuses ("model … is temporarily unavailable", a refused key), the status line says so at once, naming the provider. If the CLI then goes quiet for a minute, the turn stops and its reply is the error with what to do: try again, pick another model (the pill above the chat box), or pick another key.
Hand agents tasks
A task is one unit of work that has to outlive the turn: it runs at a time or later, repeats (every day, weekday, week, month), waits on other tasks (dependencies), gets retried, runs in the background or alongside others, is done by another agent, or comes from an automation. What's simple and can be answered now, in the conversation, isn't a task: the agent just answers. Asked in a chat for anything else ("every morning, check the leads", "research this, then draft a post from it"), an agent makes the task, says so and ends its turn; the task's own run does the work.
karsa/task/create {"frontmatter": {"title": "Import March invoices", "status": "ready",
"assignee_id": "user_…", "chat_id": "chat_…"}, "body": "From the export in Files (file_…). Skip duplicates."}
- Who does it: its
assignee_id(an agent; a person's task is theirs), else the default worker (karsa/collection/update {"id": "task", "patch": {"config": {"default_assignee_id": "user_…"}}}). - The task queue hands each
readytask over when itsdependenciesaredone, in order (rank, thenpriority, then age), as many at once as the agent'sconcurrency_limit(defaultdependencies). The agent finishes it with its result:karsa/task/update {"id": "task_…", "patch": {"status": "done", "result": "<the answer, or what you did, for the person>"}}. Karsa keeps the result in the task's notes under Result and, when it has a chat, posts it there as the agent's reply, so it's the last word: write nothing after it. If it can't:{"status": "failed", "blocked_reason": "<why>"}. A task leftin_progresswhen the turn ends is closed with the turn's last words as its result. - At a time:
"status": "ready", "when": "<ISO time with its offset>"(or a day,2026-10-30): the queue holds it until then, and it shows on the calendar at that time. That's the only way a task waits for a time;blockedmeans it waits on someone or something, and says so inblocked_reason. Superseded:"status": "cancelled". - A failed run is retried after a backoff (the task is
readyagain, held until itsnext_eligible_at), then leftfailedwith why.
Tasks made in a chat
A person can send a New task: they type / in an empty chat box and pick it, or tap a time on the calendar. The chat shows their form; you read it as text:
New task: Check Salesforce for the latest leads
When: Wed 28 Oct 2026, 12:00 (Asia/Jakarta, 2026-10-28T12:00:00+07:00)
or When: as soon as possible (a day alone reads Thu 29 Oct 2026, any time (2026-10-29)). When you're the one replying:
- Make the task:
karsa/task/createwith this chat'schat_id, your own short title, a body with what's needed, thewhenin brackets (none for as soon as possible) and"status": "ready". If what's asked is unclear, ask first, then make it. - Then end your turn: say in one line that it's made, and stop. Don't start the work, even when it's yours and as soon as possible: the task queue runs it as its own run, and holds a task made in a chat while a run there is going, so it starts as your turn ends. Working on it in your turn too does it twice.
- It becomes the chat's Topic: the chat is about it (on the task's page, the chat beside it is this one), and each turn in it you're told the task as it is now.
- Karsa posts its status in the chat: Task made, Ready, Picked up by …, Moved to …, Done, Failed: …, Needs you: …, and a title or notes a person changed on its page. The worker's run writes in this chat too: its steps fold, and its
resultis the reply. - Changed in the chat ("make it undefinedpm today, @opencode-glm"): change it,
karsa/task/update {"id": "task_…", "patch": {"when": "…", "assignee_id": "user_…"}}, and say what you changed.
Plan it, or repeat it
The Tasks page is a calendar. A task shows on it by its when:
| field | what it is | |---|---| | when | a day ("2026-10-30") or a day and time with its offset ("2026-10-30T09:00:00+07:00"). An agent's task starts at its when: the queue holds a ready task until then (a day: from its start). | | duration | minutes, for a timed task's block on the calendar (default undefined). | | repeat | a repeat rule, RFC undefined's RRULE subset: FREQ=DAILY\|WEEKLY\|MONTHLY\|YEARLY, INTERVAL, BYDAY (MO,TU,…, with DAILY or WEEKLY), BYMONTHDAY (with MONTHLY; -1 = the last day), UNTIL, COUNT. It needs a when: the first time. Weeks start on Monday. |
karsa/task/create {"frontmatter": {"title": "Weekly sales report", "status": "ready", "assignee_id": "user_…",
"when": "2026-11-02T09:00:00+07:00", "repeat": "FREQ=WEEKLY;BYDAY=MO", "chat_id": "chat_…"}}
More rules: every weekday FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR; every FREQ=MONTHLY;BYMONTHDAY=-1 weeks on Monday and Thursday FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TH; the last day of each month FREQ=MONTHLY;BYMONTHDAY=-1; ten times FREQ=DAILY;COUNT=10. A bad when or rule is refused, saying what's wrong.
- A repeating task is a series. On each day it comes up, it gets a real task, its copy: its title, assignee, chat and body, plus
series(the repeating task),occurrence(the day) and its ownwhen(that day, at the series' time, in this server's zone). Copies are made at the start of their day by the Task repeat subscribers (Advanced › Subscribers); an agent's starts at its time. The series itself is never handed over; itsstatusis what its copies start as (readyfor an agent,todofor a person). - Change one, or all, with
karsa/task/series {id, action}on the series: only+occurrence(+patch): just that one. Its copy is made now, changed.skip+occurrence: no copy that day (it goes in the series'exceptions).future+patch: all the ones still ahead. Copies already made keep theirs.stop: no more after today.
A copy already made is a task like any other: change it with karsa/task/update.
- What the calendar shows:
karsa/task/calendar {from, to}(days, at most undefined apart) →{items, unscheduled}. Items are tasks with awhen(kind: task), a task with none (as soon as possible) once it's under way or over (ready,in_progress,blocked,done,failed) at when it was picked up (claimed_at) or else made, a repeat's copies still ahead (kind: copy, computed, not stored), and the project's own time-triggered subscribers' coming runs (kind: timer; an app's, and Karsa's owncalendar: falseones, aren't shown).unscheduledlists the to-dos (todo) with nowhen.
What needs you, and today
A project's Home shows the owner what's new (their unread notifications in the project, one row per chat) and today's tasks. When someone asks "what needs me?" or "what's on today?", answer from the same places:
- Today:
karsa/task/calendar {"from": "<today>", "to": "<today>"}(the calendar's day: tasks with awhentoday, a repeat's copies, timers). - What waits on a person:
karsa/task/listand pick the tasksblockedon a person (they have ablocked_reason) and those thatfailed. Each of those also notified the owner. - What's left to set up:
karsa/home/get {}→{"setup": [...], "name": "Acme finance", "body": "…"}: a new project's cards (Get started; its first app), an enabled app that needs a key or isn't ready (kind: "app"), a built-in part Karsa couldn't update (kind: "template"). Each item:{kind, id, title, detail?, status?, link}. - To show up right, keep tasks honest:
blockedwith ablocked_reasonwhen you wait on a person,failedwith why, and say what you're doing in the task's chat. A task with a chat notifies under that chat, so the owner sees it once.
Nudge the owner: notifications
The owner hears about things in their Inbox, and on their phone when they've allowed it. Most of it needs nothing from you:
- Replying in a chat they're in (one they started or wrote in) notifies them. So does @mentioning them (their handle, from
karsa/user/list): that's how you ask them something, and it reads as waiting on them. - A task for them (made for them, or handed to them) notifies them. So, always, does a task
blockedon a person (give it ablocked_reason) and a task thatfailed(say why). - Anything else worth their attention (a deploy failed, a long job finished):
karsa/notification/create.
karsa/notification/create {"title": "The nightly import failed", "body": "The bank's file was empty.", "link": "/project/proj_…/task/task_…", "urgency": "high"}
→ {"id": "notification_…", "unread": 3}
titlesays what happened;bodyone line more (your name is put in front: "Ari: The bank's file was empty.");linkthe Karsa page it's about (default: this project);urgency: "high"when it waits on them.groupcollapses yours with the same key while they're unread:{"group": "nightly-import", …}makes the latest night's news replace the last ("undefined new …"), not stack up. Without one, each stands alone.reason:nudge(the default),waiting(it waits on them; the default withurgency: "high"),following. Mentions, keywords and replies are Karsa's to tell.- Who it's from: an app's notifications show as the app's (
source: app:<slug>, and they can mute that app's); an agent's as yours. - "Ping me when it's done": when they ask, finish the job, then
createone with the result and itslink. - Use it for what they'd want to know now, not for every step: what you're doing goes in the chat or the task, where they read it when they look. A burst from one place (three replies in a row) reaches their phone as one.
What notifies them is theirs to set (karsa/notification/rules, the owner only):
karsa/notification/rules {}
→ {"default": "replies", "projects": {"proj_…": "all"}, "items": {"chat_…": "muted", "app:invoices": "mentions"}, "keywords": ["invoice", "Budi"]}
- Levels, the most specific set one winning (a chat or task, then its project, then the default):
all: every new message and task change;replies(the default): replies in chats they're in, tasks for them, mentions;mentions: only @mentions and their keywords;muted: nothing.
A mention, something waiting on them and a failed task always notify, unless muted.
keywordsnotify them whenever one comes up, whole word, any case, in any message or task title or body they can see, including chats they're not in.karsa/notification/listfilters byreason(keywordfor all keywords),project,sourceandunread.- You can't change their rules. If they ask you to ("notify me whenever invoice comes up"), tell them where: Inbox › What notifies you › Your keywords, or the 🔔 on a chat or task.
- Where it reaches them is theirs too (
karsa/notification/destinations, the owner only): each device they turned notifications on for (Settings › Notifications), with what goes there (everything,mentions_waiting,waiting). Email and app-offered places come later. - Don't nudge around a mute unless it's urgent.
Create and manage an agent
Everything here is an op on the project's API (POST $KARSA_API_BASE/rpc {"method": …, "params": …}). An agent asked to "make me an agent that…" does exactly this.
undefined. Pick its harness. A harness app's check names its runner, and the agents on it:
karsa/app/check {"id": "opencode-mango"} → harness: {wake: "fn_…", agents: [{id, name, slug, default_responder, default_worker}]}
Another model on the same harness is another copy of the app: karsa/package/install {"package": "@karsa/opencode-mango", "as": "opencode-kimi"}, then set its model (karsa/app/configure {"id": "opencode-kimi", "config": {"model": "moonshotai/Kimi-K*"}}; karsa/app/config-options {"id": "opencode-kimi", "field": "model"} lists what your key can use) and enable it. Its install makes it a starter agent too (@opencode-kimi).
undefined. Start from a persona. karsa/agent/templates lists the ones Karsa ships (below); or write your own.
undefined. Create it.
karsa/user/create {"frontmatter": {"_slug": "reviewer", "name": "Reviewer", "kind": "agent",
"wake": "fn_…", "concurrency_limit": 1}, "body": "You review what you're pointed at: bugs and risks first, …"}
That makes its folder (index.md, docs/, context/) and its two notification subscribers. @reviewer now answers in chat, and takes tasks assigned to it.
undefined. Give it docs and context. Write files in its folder: docs/ is reference only it reads (house style, a client's glossary, where things are), context/ is its memory, which it keeps itself.
Its own keys. An app asks for a kind of key ("Mango Inference API key"); a secret is a named value someone owns (_ownerId): yours, or an agent's. You pick which secret an app uses, on its page; an agent uses that one, unless one is picked for it. So two agents on OpenCode on Mango can each use their own Mango key, and one with none uses the app's. Every secret lives in the System project, an agent's too: its keys are the ones it owns, and a project's Secrets lists yours and its agents'. Secrets are own: an agent sees and changes only its own, and you see all of them. Deleting an agent deletes its keys.
- On its page: Keys, below its persona: Same as OpenCode on Mango Inference (the app's pick), one of its own, one of yours, or New key… (its own). An agent on an app another one includes (OpenCode inside OpenCode on Mango) has its keys picked for that outer app.
- From a terminal:
karsa secret create --title "Mango for reviewer" --owner reviewer --value(it asks for the value without echo), thenkarsa agent set-key reviewer --env MANGO_INFERENCE_API_KEY --secret <its id>. - As ops:
karsa/secret/create {"title": "Mango for reviewer", "owner": "reviewer", "value": "…"}, thenkarsa/agent/set-key {"id": "reviewer", "env": "MANGO_INFERENCE_API_KEY", "secret": "secret_…"}(null: back to the app's).karsa/agent/keys {"id": "reviewer"}lists them.
Picking is the yes. Nothing goes to an app until a key is picked for it, by the app's id: a look-alike app asking for the same kind of key gets nothing, and on its page each of your keys says where it's used already ("Mango — personal · used by OpenCode on Mango Inference"). Only a secret's owner, or you, picks it: an agent can pick its own, not yours. The app's fns see the value and can keep using it, so pick a scoped key with a spend limit. With nothing picked, the agent says so in the chat ("I can't reach Mango Inference yet: no key is picked for it…"). A secret's page lists where it's used, and renames it.
undefined. Change it.
| To | Do | |---|---| | change its persona | karsa/user/update {"id": "user_…", "patch": {}, "body": "<the new persona>"} | | move it to another harness or model | karsa/user/update {"id": "user_…", "patch": {"wake": "fn_<the other runner>"}}: its subscribers follow | | rename its handle | karsa/user/update {"id": "user_…", "patch": {"_slug": "invoicer"}}: its chat subscriber follows | | make it the default responder | karsa/collection/update {"id": "chat", "patch": {"config": {"default_ai_reply_user_id": "user_…"}}} | | make it the default worker | karsa/collection/update {"id": "task", "patch": {"config": {"default_assignee_id": "user_…"}}} | | let it work two tasks at once | karsa/user/update {"id": "user_…", "patch": {"concurrency_limit": 2}} | | delete it | karsa/user/delete {"id": "user_…"}: its folder and subscribers go too |
undefined. Its identity and rights. An agent is a user, so it has sessions like a person: each of its runs authenticates with one, so what it writes is stamped as it (_created_by) and every call is checked against what it holds.
- What it holds:
permission/fullwhen the owner makes it, which is everything in its project and nothing beyond it (the server's own steps go by the System project:doc_using-karsa, Who reaches what). Narrow it later:karsa/user/update {"id": "user_…", "patch": {"permissions": ["permission/task/*", "permission/chat/*", "permission/msg/*"]}}. An agent made by another agent gets at most its maker's. - Its subscribers run as it, each with a session of its own (
_sessionon the record, made with it, ended when it's deleted); a subscriber's optionalpermissionsnarrows its runs further. The owner may make a subscriber that runs as anyone; anyone else's runs as themselves. - Its CLI gets the run's token in
$KARSA_API_BASE, so its calls are it too. A run's own calls are also capped by what its fn declares. - Tokens by hand:
karsa/session/create {"user": "user_…", "permissions": ["permission/task/*"]}(the owner, for an agent),karsa/session/list,karsa/session/revoke {"id": "ses_…"}. - A token and an OS user together. The token keeps what an agent does through Karsa its own and within its rights; its OS user keeps it out of what isn't its own on the machine: it can't open Karsa's owner socket (that's you), read your logins, or reach another agent's folder. Its page says which OS user it runs as (
karsa/system/run-astoo).
undefined. Its notifications. Its two subscribers carry user: <it> and notify: chat or notify: task: karsa/subscriber/list {"q": "user = 'user_…'"}. Their matches are its level:
- chat, mentions (the default):
[{"type": "karsa/msg/created", "attrs": {"author_kind": "human", "mentionedUsers": "<its handle>"}}]. - chat, every message in one chat (an agent that follows a channel):
karsa/subscriber/update {"id": "sber_…", "patch": {"matches": [{"type": "karsa/msg/created", "attrs": {"author_kind": "human", "chat_id": "chat_…"}}]}}. - task, handed to it (the default):
[{"type": "karsa/task/claimed", "attrs": {"assignee_id": "user_…"}}]. - Pause it:
karsa/subscriber/set-run-mode {"id": "sber_…", "mode": "paused"}(on both, to pause the agent).
Wake an agent from code
From a fn (an automation), two ways:
- Hand it a task (usually right):
karsa/task/createwith"status": "ready", itsassignee_idand achat_id. It's queued, retried, visible, and reported in the chat. The fn needspermissions: {docdb: [task]}. - Run its wake directly, for something immediate:
karsa/run/create {"fn": "<its wake>", "detach": true, "assignee_id": "user_…", "chat_id": "chat_…", "params": {"agent": "user_…", "instruction": "…", "chat_id": "chat_…"}}. A run is its caller: you can wake yourself this way, and only the owner can run it as another agent. The fn needspermissions: {ops: [run/create]}.
A message posted by code doesn't wake agents: their chat subscribers listen to people's messages.
Example: an ingress, every request an event
An ingress is a public URL on Karsa Cloud that turns each request into an event in a project, for webhooks (GitHub, Stripe, Slack, a form). It needs this server linked to Karsa Cloud and its tunnel up (doc_using-karsa, Reaching Karsa from away).
undefined. Create it, for this project. The full URL (its secret included) is shown once:
`` karsa/ingress/create {"display_name": "GitHub", "project": "proj_…"} → {id, uuid, url, …} ``
path. Each request becomes karsa/ext/ingress in the project, with {ingress_id: <its uuid>, method, path, query, headers, content_type, body, body_encoding} (path is what follows the secret, body is text for JSON/text/forms, else baseundefined; cookies and auth headers are dropped, signature headers kept). The caller gets 202. body. Subscribe a fn to it:
`` karsa/subscriber/create {"frontmatter": {"fn": "fn_…", "run_mode": "running", "matches": [{"type": "karsa/ext/ingress", "attrs": {"ingress_id": "<its uuid>"}}]}} ``
The fn gets {event: {event_type, payload, created_at}, params}. To try it without a request: karsa/event/create {"type": "karsa/ext/ingress", "payload": {"ingress_id": "<its uuid>", "method": "POST", "path": "/github", "body": "{}"}}.
Example: store every event, wake the agent in batches
Webhooks can come by the hundred. Rather than waking an agent for each, store each one as a row in the project's SQL database, and wake the agent when undefined have piled up, or every undefined minutes when there's anything new.
The fn (inbox_sink):
import { defineFn } from "@karsa/sdk";
// Each event becomes a row in `inbox`; the agent is handed a task to review them
// every 20 new rows, or (from the timer, with no event) whenever any are new.
const EVERY = 20;
const AGENT = "user_…"; // the agent to wake
const CHAT = "chat_…"; // where it reports
defineFn(async (input, karsa) => {
const { event } = input as { event?: { event_type?: string; payload?: unknown; created_at?: string } };
await karsa.op("karsa/sqldb/exec", {
sql: "CREATE TABLE IF NOT EXISTS inbox (id INTEGER PRIMARY KEY, type TEXT, payload TEXT, at TEXT, handled INTEGER NOT NULL DEFAULT 0)",
});
if (event) {
await karsa.op("karsa/sqldb/exec", {
sql: "INSERT INTO inbox (type, payload, at) VALUES (?, ?, ?)",
params: [event.event_type ?? "", JSON.stringify(event.payload ?? {}), event.created_at ?? new Date().toISOString()],
});
}
const [{ n }] = await karsa.op("karsa/sqldb/query", { sql: "SELECT count(*) AS n FROM inbox WHERE handled = 0" }) as { n: number }[];
if (event ? n < EVERY : n === 0) return { new_rows: n };
// One review at a time: not while the last one is still open.
const open = await karsa.op("karsa/task/list", {
q: "title = 'Review the inbox' and status in ('todo', 'ready', 'in_progress', 'blocked')",
}) as unknown[];
if (open.length > 0) return { new_rows: n, review: "already open" };
await karsa.op("karsa/task/create", {
frontmatter: { title: "Review the inbox", status: "ready", assignee_id: AGENT, chat_id: CHAT },
body: `${n} new rows in the \`inbox\` table (handled = 0). Read them with karsa/sqldb/query, ` +
"report what matters in this task's chat, then mark what you read: " +
"UPDATE inbox SET handled = 1 WHERE handled = 0 AND id <= <the last id you read>.",
});
return { new_rows: n, review: "handed to the agent" };
});
Create it, with what it may do in Karsa (none: it can call no ops):
karsa/fn/create {"slug": "inbox_sink", "code": "<the code above>",
"permissions": {"docdb": ["task"], "ops": ["sqldb/query", "sqldb/exec"]}} → {fn_id: "fn_…"}
Two subscribers run it: on each event, and every undefined minutes.
karsa/subscriber/create {"frontmatter": {"fn": "fn_…", "run_mode": "running",
"matches": [{"type": "karsa/ext/ingress", "attrs": {"ingress_id": "<its uuid>"}}]}}
karsa/subscriber/create {"frontmatter": {"fn": "fn_…", "run_mode": "running", "trigger": {"every": "15m"}}}
The agent wakes with the task, reads the rows (karsa/sqldb/query), reports in the chat and marks them handled. The rows stay, so you can ask about them later; to see them as a list in the sidebar, make the table a collection: karsa/collection/create {"frontmatter": {"slug": "inbox", "name": "Inbox", "plural": "Inbox", "source": "sqlite.row", "sqlite": {"table": "inbox"}}}.
To store every event, not just one ingress's, match {"type": "*"}, but leave out what the review itself causes (its task's events, the agent's messages): list the types you want instead, e.g. karsa/ext/* for everything from outside. karsa/event-schema/list lists the event types.
Change the fn later with karsa/fn/update {"id": "fn_…", "code": "…"} (or its permissions).
Example agents
Karsa ships these personas (karsa/agent/templates), to start from:
| Persona | For | |---|---| | General | answers, builds and works the tasks handed to it; every harness's starter agent | | Reviewer | reviews changes, apps and documents: bugs and risks first | | Researcher | finds things out, compares options, cites sources | | Watcher | woken by events and schedules: reads what's new, reports what matters (the batch example's agent) | | System | the System project's agent: configures this server, installs packages |
When something's off
- Its harness app's Overview → Check again: is the CLI still there?
- Open the run (the exec id in the status line): its log starts with the CLI, the folder, the model and the session it used, then the CLI's own output. A failed turn ends the status line with
failed, and the chat says why. - A conversation keeps its session, one per chat or task, until the project's folder changes; then the next turn starts a fresh one.
- It can't ask you for permission mid-turn. OpenCode refuses anything that needs approval, such as files outside the project's folder; the agent is told and carries on.
- A turn that hangs is stopped: one that finished but didn't exit after undefined seconds (its reply stands), or one silent for undefined minutes (the chat says why).
- No key, or no CLI: the agent says so in the chat; a task it was handed waits
blockedwith why. - Uninstalled its harness: the agent stays (it's yours); point its
wakeat another harness. karsa doctorlists every app's check across projects.