Listen
0:00 / ~12:46
A Kavanah app adds your own capability to the workspace in two ways: as tools the AI agent can call, so anyone can just ask for it in chat, and as screens of your ownrendered inside Kavanah that can look native down to the viewer's theme. You build the app in the developer console, host the logic yourself, install it, and both become part of the workspace.
Kavanah hosts the manifest; you host the code. The manifest declares what your app can do — its tools, their inputs, and the scopes it needs. When the agent decides to use one of your tools, Kavanah sends a signed HTTPS request to an endpoint you run, and your response goes back to the agent as the tool result. There is no code editor in Kavanah and nothing runs on our servers: an app is always a small web service you deploy anywhere.
/apps and click Developer console (admins only). Choose New app, then Create app — you get a starter manifest you can edit as a form or as raw JSON.@kavanah/app-sdk and point execution.tool_endpoint_url at it. The SDK verifies the signature and dispatches to your handler.test flag. If that works, the agent path works.Your tools are namespaced app__<slug>__<tool> so they can never collide with a built-in, and they are offered to every member's agent in the workspace — an install is workspace-wide, not per-user. Kavanah exposes the tools from the exact app version you installed, so a publisher cannot add tools or swap your endpoint on an already-installed workspace; upgrading is an explicit re-install.
Every app tool is treated as an external write: it is excluded from the agent's safe autonomous mode, governed by your workspace action policy (so it can be set to require approval), recorded in the action ledger, and withheld from restricted personas and read-only runs. Tool output is fenced as untrusted, validated against your declared output schema, and size-capped.
Each install can mint a per-app API key (kvn_app_…). It authenticates as the app, not as the person who installed it, so it has no workspace permissions at all — its granted scopes are the entire authorization. That means it can reach only the endpoints Kavanah has opened to apps:
tasks, projects, clients, discussions, project-files and time-tracker — read with …:read, create and update with …:write.import { KavanahClient } from "@kavanah/app-sdk";
const kavanah = new KavanahClient({
apiKey: process.env.KAVANAH_APP_KEY!, // kvn_app_...
baseUrl: "https://kavanah.ai",
});
const tasks = await kavanah.listTasks("all");
await kavanah.createTask({ title: "Follow up", projectRef: "1" });Anything outside that list returns 403 by design. The authoritative surface is every operation marked x-app-scope in the OpenAPI spec:
/openapi.yamlAn app can do more than hand tools to the agent — it can render its own pages inside Kavanah. Declare them in the manifest's ui array. Each entry is a page you host, loaded in a sandboxed iframe, and it can look indistinguishable from the rest of Kavanah.
"ui": [
{
"id": "overview",
"type": "page",
"mount": "nav",
"title": "Pipeline overview",
"description": "Deals by stage, straight from our CRM.",
"entry_url": "https://example.com/kavanah/overview",
"scope": "clients:read"
},
{
"id": "settings",
"type": "settings",
"mount": "app_settings",
"title": "Connection settings",
"entry_url": "https://example.com/kavanah/settings",
"height": 420
}
]id — lowercase, URL-safe, unique in the app. It appears in the page URL: /apps/<slug>/<id>.type and mount — where it renders. page/nav is a full screen reachable from Apps; settings/app_settingsrenders inline in your app's row on the Apps page. Each type accepts only the mounts that make sense for it — the console's dropdown is generated from the same table the validator uses, so it cannot offer an invalid pairing.entry_url — https, a public host, and not a Kavanah URL. A surface must be a separate origin; that separation is what the sandbox depends on.scope — optional. If set, the screen is hidden unless the workspace granted that scope, and it must be one your app requests.height — optional starting height in px. Your page can resize itself at runtime, so this only governs first paint.Kavanah does not ship its React components — a cross-origin iframe could not import them, and freezing them into a public package would stop us improving them. What you get instead is the design-token contract: load one stylesheet, and the host pushes Kavanah's live values for colors, radii, elevation and the type scale. Your screen then matches whatever the viewer is actually using — dark or light, their accent, even their font-size accessibility preference — and follows along when they change it.
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="https://kavanah.ai/kavanah-app-ui.css">
</head>
<body class="kvn-root">
<div class="kvn-page">
<h1 class="kvn-title">Pipeline</h1>
<div class="kvn-card">
<div class="kvn-card__header">
<h2 class="kvn-card__title">Open deals</h2>
<span class="kvn-badge kvn-badge--accent">12</span>
</div>
<button class="kvn-btn kvn-btn--primary" id="sync">Sync now</button>
</div>
</div>
<script type="module">
import { connectKavanah } from "https://esm.sh/@kavanah/app-sdk/ui";
const kvn = await connectKavanah();
kvn.autoResize();
// Send the signed token to YOUR backend — never trust the client fields.
const res = await fetch("/api/deals", {
headers: { Authorization: `Bearer ${kvn.token}` },
});
document.getElementById("sync").onclick = () => {
kvn.toast("Sync started", "success");
};
</script>
</body>
</html>The stylesheet gives you buttons, cards, inputs, tables, badges, tabs, alerts, empty states, spinners and skeletons — every class prefixed kvn-, every value driven by a --kvn-* custom property. Use those properties in your own CSS too; that is how custom UI stays in step with the theme. Everything has a sensible fallback, so a screen opened directly in a browser tab still renders correctly while you develop.
Your page is on your own origin, so nothing the browser tells it can be trusted — a query parameter naming a workspace is an invitation to read someone else's data. Kavanah therefore mints a short-lived surface token, signed with the same install signing secret you already use to verify tool calls. Send it to your backend and verify it there. The verified claims are the only authority.
import { verifySurfaceToken } from "@kavanah/app-sdk";
const result = await verifySurfaceToken(token, process.env.KAVANAH_INSTALL_SECRET!, {
audience: "my-app-slug", // always pass this
});
if (!result.ok) return new Response("Unauthorized", { status: 401 });
const { workspace_id, install_id, surface_id, granted_scopes } = result.claims;
// result.claims.user_id exists only if you set execution.include_actor_identitykvn.token at the moment you make a request rather than caching it. It proves who is embedding you — it is not a Kavanah API credential. Calling back into Kavanah still needs your kvn_app_… key.To try one: open the developer console, select your app, and use Preview on the screen card. It runs the real handshake against your real install, so anything that would break in production breaks there — a blocked frame, a page that never calls connectKavanah(), a rejected token.
Four things account for nearly every blank or stuck surface, in the order they are worth checking:
X-Frame-Options: DENY or a restrictive Content-Security-Policy: frame-ancestors on your page. A Vercel project with Deployment Protection enabled does exactly this: it answers 302 to a login redirect and sends X-Frame-Options: DENY, so a protected preview or team deployment can never be a surface. Turn protection off for that project, or host the surface somewhere public. Verify with curl -sI <your entry_url> — you want a 200 and no framing headers.Surface not found until someone re-installs and approves it. That is deliberate.connectKavanah(). Without the handshake the host has no one to send the context to, and gives up after 15 seconds.Scopes are how an app states its intent up front and how a workspace consents to it. There are two families. Data scopes — tasks, projects, clients, messages, files, time_entries, each :read or :write — control both what the REST key can call and whether a tool is exposed at all. Action scopes — such as external_send, finance or destructive— mirror the agent's action categories and act as a ceiling that the workspace action policy still governs at call time.
Grants are re-checked on every single request, not frozen at mint time. Narrowing an install, disabling it, or uninstalling immediately narrows or cuts off every key and tool it ever issued.
Everything above can be driven from chat by a workspace admin. Tell the agent what the app should do and where it will be hosted; it drafts a manifest that already passes the validator, shows it to you, creates the app, and generates the complete starter project for the server half — one handler per tool, the signed-request route at the path your manifest declares, a page per screen, and a README with the deploy steps. Once you have deployed it, the agent can install the app, grant scopes, check that the endpoint answers, send a signed test call and show you exactly what your server replied, publish new versions, and roll one back.
https://example.com/…) if you have not named a host yet. The manifest's warnings and the generated README both say so; publish a version with the real URL before installing.test: true in the payload and an X-Kavanah-Test header. Handle that flag if the tool has side effects you would not want from a test.nav (a full page under Apps) and app_settings (inline on the Apps page) render today. task_panel, project_panel and dashboard_widget validate and are stored, but nothing displays them yet — declare them only when you are ready for them to appear.task.created are accepted in the manifest but not yet sent, and delivery is best-effort rather than queued.$ref is rejected, and formats, enums and numeric bounds are not enforced at runtime.