← The Karsa Manual

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:

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

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.

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."}

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:

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 copy already made is a task like any other: change it with karsa/task/update.

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:

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:

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}

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"]}

A mention, something waiting on them and a failed task always notify, unless muted.

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.

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.

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:

Wake an agent from code

From a fn (an automation), two ways:

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