kareenosdocs

Builder guide for MCP clients

The complete reference Claude Code follows when building on Kareenos: contracts, shapes, error codes, and the hosted web application flow.

On this page

This guide is distributable. Hand it to anyone building on Kareenos through the MCP connector with Claude Code (or any MCP-capable AI agent). It describes only the public tool surface: the same contracts the tools themselves teach through their descriptions and corrective error messages.

The live channel always wins. kareenos_context({topic}) and get_skill(...) are assembled from your running deployment; when they disagree with this document, they are right. Every failed tool call returns a message that states the exact fix. Follow it, and never retry an unchanged call.

Twin copies. The canonical file is docs/platform-design/kareenos-mcp-builder-guide-for-clients.md. kareenos_backend/scripts/sync_builder_guide.js copies it into the connector (kareenos_context({topic:"builder_guide"})) and into the public docs (Developers, Builder guide for MCP clients); the frontend build runs it automatically as its first step. Edit the canonical file only, and keep it free of em-dashes: the generator refuses one and the build stops.


0. The short version

Add the Kareenos MCP to Claude Code, then build. Kareenos is your backend, your agentic ecosystem and your host.

Two things get built on Kareenos, and these are their names:

Name What it is Who uses it
Kareenos Agentic Web-App A web application hosted by Kareenos at https://<app_name>.kareenos.com. You write the frontend (Vue 3 + Quasar v2) and its backend (K-API routes bound to handler K-Jobs) together. Its user_mode says who signs in: your own customers, who register in the app (third_party_tenants), or your own team with their Kareenos login (kareenos_users). Your customers, or your team, in a browser, at the app's own address.
Kareenos Agentic Business Project The operation you run INSIDE Kareenos: Live Workbook sheets, K-Jobs, K-Agents, widgets, pages, dashboards and reports. Your own team, in the Kareenos console.

They are not alternatives. A Web-App keeps its data and its logic in a Business Project, so building a Web-App normally means building both. Sections 2 to 9 are the Business Project contracts; section 10 adds the Web-App on top. (A Kareenos Agentic Mobile-App is planned and cannot be built yet.)

One command gets you started: /kareenos:kareenos_start_app. It works out which of the two you want from what you described, loads the right guidance and the full guide, then builds. You can also just describe what you want: the connector's own instructions carry the same flow. For a Web-App:

  1. kareenos_context() then kareenos_context({topic:"web_apps"}) (the doctrine and the loop).
  2. Settle who signs in (section 10.0), then create_app({app_name, description, projectid, user_mode}): the app name becomes https://<app_name>.kareenos.com.
  3. Per feature: a sheet, a handler K-Job, a K-API route, the screen that calls it, test_k_api.
  4. quasar build, zip the output folder, shasum -a 256, request_app_build_upload, run the curl.
  5. get_app_hosting_status({probe:true}), open the URL, iterate.

Starting from an existing folder instead? A fork or a clone of someone else's frontend, typically after you imported their project from the marketplace, still carries THEIR ids. Say "link this project to my imported project" (or run /kareenos:kareenos_link_app): one call to link_app_source connects the folder to your own copy. Section 10.11.

When the build works, the last phase begins. The Agentic Operations Phase hands what you built to the in-app Super Agent: it studies the live operation, proposes the AI Agents that could run it with your team, and builds and wires the ones you pick. Claude Code offers it once at the end of a build; start it any time with /kareenos:kareenos_agentic_operations. Section 12.

Three rules never bend: the frontend and its backend are built together; the handler K-Job is the only door from the app into the ecosystem; all application data lives in Live Workbook sheets and nowhere else. Section 10 explains each.

1. Connect

  1. In Kareenos, open Settings, Exposures and create an exposure in tools mode. Copy the key. It is shown once.
  2. Add the server to Claude Code (the URL must be absolute; restart the Claude Code session after adding):
claude mcp add --transport http kareenos https://<your-kareenos-domain>/mcp \
  --header "Authorization: Bearer <your kmcp2_… key>"
  1. First call, always: kareenos_context with no arguments (or run the kareenos_start_app prompt, which does this and more in one step). It returns the orientation guide plus the topic list (project_composition, data_model, jobs, agents_authoring, widgets, actions_reports, documents, limits_and_errors, web_apps, link_source, agentic_operations, builder_guide). It is free; read the topic for each area before you build in it.
  2. Before a substantial build in any domain, load the platform's own playbook: get_skill_list, then get_skill("<name>") (for example live_workbook_design, widgets_author, k_job_design, pages_author, k_api_builder).

Every tool call is metered against your account's credits (writes cost more than reads); kareenos_context is free. Results share one envelope: {status: "success"|"failed", message, data, errors}. On "failed" nothing was saved.

2. The build order

A complete solution composes in this order. Each step's returned ids feed the next:

  1. Project (add_k_project): the container. Widgets, actions and reports are project-scoped and take its projectid.
  2. Data: Live Workbook sheets (lw_create_sheet). Always pass projectid so the workbook is linked to the project in the same call. Widget and action data grants only validate against linked workbooks (an unlinked one fails every later widget save with a corrective message; link_component_to_project repairs it).
  3. Automation: K-Jobs. create_k_job (lands awaiting approval), dry_run_k_job (reads are real, side effects are captured and listed, nothing is applied), fix, approve_k_job, trigger_k_job_now to smoke-test.
  4. Agents (when needed: the ones the user asked for, such as the assistant a screen calls; the operation's agent team comes in step 8): add_k_agent, add_k_agent_version (always prepend the output of get_k_agent_system_rules to the prompt), assign_tool_to_k_agent per tool (zero assignments = zero tools), activate the version.
  5. UI: dialog/editor widgets first, then the screens that open them, then any dashboard widget (it needs its processor job's id). Then actions and reports.
  6. Seed, verify, place: sample rows, test_widget / test_widget_render / run_report, then Pages (add_page_section, add_page, add_widget_to_page).
  7. Readiness: get_solution_readiness({projectid}) scans for gaps; get_project_components inventories what you built.
  8. Hand off, the last phase: offer the Agentic Operations Phase once. On yes, handoff_to_super_agent opens the in-app Super Agent on the project with your briefing, and it proposes, builds and wires the AI Agents that run the business (section 12).

Keep an id ledger. Every create returns ids (sheet ids, column col_keys, job ids, widget ids) that later steps consume. Column keys especially are generated and cannot be guessed. Finish every build with a handover document (create_document) listing the full inventory and any remaining manual steps: your next session inherits only what you wrote down. In a Web-App source folder the platform itself is the ledger: never write ids into files you commit, ask for them with link_app_source({app_id, detail:"full"}) (section 10.10).

3. Working with Live Workbook data

The row shapes, which everything else depends on:

Operation Shape
Insert (lw_add_rows / lw_write op:add) rows: [{"<col_key>": value, ...}], bare 6-hex column keys
Update (lw_update_rows) rows: [{row_id, values: {"<col_key>": value}}]
Delete (lw_delete_rows) row_ids: ["..."]
Read (lw_query_rows / lw_read) rows come back keyed "<col_key>_value", plus _row_id and _created_at/_updated_at
Filters [{col_key, op, value}] (array = AND) or {and:[...]} / {or:[...]}; ops: eq neq gt gte lt lte in not_in contains starts_with is_null not_null; _row_id and _created_at are filterable
Sort `[{col_key, dir: "asc"

Rules that save you an afternoon:

  • Discover before writing: lw_list, then lw_get_sheet gives every column's col_key, type and config.
  • Inserts return data.row_ids in input order. Capture them for relation columns.
  • Relations are ref columns: value_type: "ref" stores the referenced row's _row_id; config.ref = {sheet_id, display_col_key, on_delete: "set_null"|"restrict"}. Writes are validated (REF_NOT_FOUND), the column is indexed automatically, reads return a resolved "<col_key>_label" beside the id, and deleting referenced rows honors on_delete (REF_RESTRICT refuses with the referencing sheet named). Design every join on refs, never on free-typed names.
  • required / is_unique are enforced by the database: a violating write returns field_errors [{col_key, code, message}] and saves nothing.
  • From widget code, always pass return_mode: "rows" on lw_query_rows (the default mode switches large results to a cached handle that widget JavaScript cannot read), and page with page_size (max 500).
  • Address / media / QR / barcode cells are JSON strings: JSON.parse before use, and fill them from the producing tools' ready-made cell objects (geocode_address gives address_cell, put_file gives media_cell, kwidget.uploadFiles gives one media cell per uploaded file, generate_code gives qrcode_cell), never hand-assembled.

The heavy-application verbs:

Verb What it buys
lw_write_batch ONE atomic transaction across up to 5 sheets / 200 rows: the header+lines save (header row id comes back in data.ops[0].row_ids); any failure means nothing was saved
if_version on update patches optimistic concurrency: pass the _version you read; a stale row fails with VERSION_CONFLICT instead of silently overwriting
lw_next_number race-free document numbers (INV-2026-0042); yearly reset is the sequence NAME; write the value into an is_unique column
lw_distinct distinct values of one column over the whole sheet: filter dropdowns without loading rows
include_children on lw_query_rows per-parent child counts/sums joined onto the page in one call (child column holds this sheet's row ids)

4. Widgets: the in-app UI

A K-Widget is one self-contained HTML document. Input/form/table widgets use the hosted Quasar runtime: author the bare form (one <div id="app"> template plus one inline setup script ending .use(Quasar).mount('#app')); the host injects Vue 3, Quasar 2, the brand theme and the icon set. Display-only tiles can stay plain HTML.

Validated at save (all-or-nothing, every rejection names the fix):

  • Only the allow-listed q-* components pass; q-dialog/q-menu and layout scaffolding are excluded. Real dialogs open in the HOST via kwidget.openWidget / kwidget.openAction, and confirms are inline two-step banners.
  • No setInterval, no window.confirm/alert/prompt, no browser storage, no external resources or CDNs, no <input type="file"> (uploads use kwidget.uploadFiles, below), no literal hex colors (use the injected var(--kn-*) design tokens; hex is allowed only as a var() fallback and inside chart colors: [...] arrays), one primary button per action bar, kebab-case attributes, custom elements never self-closed, v-html never bound to data.
  • CSS namespace rule: widget styles are document-global and the framework uses utility class names (col, row, column, block, fit, items-*, justify-*, and more) on its own internals. Defining any of those names in widget CSS breaks every button, chip and input (rejected as WIDGET_CLASS_SHADOWS_QUASAR). Prefix every widget class, for example .kb-col, .kb-card. Descendant selectors that merely target framework classes (.q-table th {...}) remain fine.

Capability grants (fail-closed, validated at save): everything the widget's own JavaScript calls must be granted:

capabilities: {
  tools:     ["tool.lw_query_rows", "tool.lw_add_rows"],        // every kwidget.callTool name
  lw_sheets: [{sheet_id: "...", mode: "read"|"write"|"readwrite"}], // AND per-sheet grants for row tools
  jobs:      ["<job_id>"],                                       // kwidget.runJob targets
  widgets:   ["<widget_id>"],                                    // kwidget.openWidget targets
  actions:   ["<action_id>"], agents: ["<agent_id>"]
}

Row tools need BOTH the tool entry and a matching lw_sheets entry; sheets must live in workbooks linked to the widget's project. On update_widget, omitted (or null) capabilities keeps the stored grants, {} revokes them all, and an object replaces them wholesale, never merges.

The CRUD pattern: editor/form widgets are separate, unplaced widgets. A screen opens one with kwidget.openWidget(id, {title, min_width, prefill: {row_id}}); the editor reads kwidget.getContext().prefill, loads its row by _row_id, and finishes with kwidget.close({saved: true, created: {label, value}}); the opener refreshes when r.result.saved is set. All interactive wiring goes inside kwidget.ready(cb), gated by kwidget.can(cap). Every write ends in a visible outcome: :loading on the button, an inline success banner that NAMES the artifact (auto-dismissed with one setTimeout), the specific error kept next to the form, and two-step banner confirms for destructive verbs.

Uploads, many files at once: a widget never hosts a file picker. A button calls kwidget.uploadFiles(...) and the HOST opens an upload dialog: the user drops or picks many files, and each file starts uploading at once with its own progress bar (video mp4, mov or webm up to 200 MB, other files up to 25 MB). The promise resolves when the user closes the dialog, with {files: [mediaCell], failed: [{file_name, size_bytes, error}], cancelled}. onProgress reports every file, and its done event carries that file's media cell, so each row can land as soon as its file is stored. Write each cell as it is into the sheet's media column, one row per file, and prefill what you can (the title from file_name, today's date) so the user types nothing per file; details are edited later in the grid. The upload itself needs no grant; the row writes keep their tools and lw_sheets grants. Keep max_files at its default of 20 when you write a row per file: that stays under the per-user limit of widget tool calls per minute.

kwidget.uploadFiles({
  kinds: ["video"], multiple: true, title: "Upload videos",
  onProgress: function (p) {
    if (p.status !== "done") return
    var row = {}
    row[COL_TITLE] = p.file.file_name.replace(/\.[^.]+$/, "")
    row[COL_FILE] = p.file                       // the media cell, as it is
    kwidget.callTool("lw_add_rows", { sheet_id: SHEET_ID, rows: [row] })
      .catch(function (e) { failedRows.value.push(p.file_name + ": " + e.message) })
  }
}).then(function (r) {
  if (r.cancelled) return
  banner.value = r.files.length + " added" + (r.failed.length ? ", " + r.failed.length + " not uploaded" : "")
  kwidget.refresh()
})

Use kwidget.openAction for a file only when it belongs to an authored action's answer.

Verify before declaring done: test_widget (data contract; processor-less widgets get validation-only mode) and, where your deployment provides it, test_widget_render: a headless render returning console errors, layout findings (page-level horizontal overflow, oversized controls, blank render) and a screenshot URL. Any finding means not done; "oversized controls" almost always means the CSS namespace rule above was broken.

5. Actions: forms that reach mobile

An action is a deliberately simple structured form that agents send by name and that renders in the inbox on web and mobile. add_action takes an emit_template ({title, intro?, blocks: [...]}) of display blocks plus {kind: "fields"} input blocks (field kinds include short_text, number, date, time, single_choice, media, address, lookup).

  • Lookup fields reference sheet rows: lookup: {sheet_id, value_column: "_row_id", label_column: "<col_key>"}. The submitted value is the row id.
  • on_submit (top level, up to 5 ops) writes the answers to sheets: [{op: "add_row", sheet_id, values_map: {"<col_key>": "$fieldId" | {literal: x}}}] (also update_rows / update_cell / delete_rows with row_ids_from).
  • The action's own capabilities.lw_sheets covers its bindings (read for lookups, write for on_submit). The response schema is derived from the fields; never write it.

6. Reports: printed, branded, shareable

Reports have no processor: the runtime executes the report's declarative queries directly, tenant-fenced (never filter by account yourself).

  • sheet_sql queries reference sheets ONLY as {{sheet:<id>}} alias, use the human column aliases from get_report_sheet_schema (never raw column keys), pass values via $1..$n params, and are one statement: no ;, no comments, no writes. Rows also expose _row_id, so parent/child joins are child.parent_fk = parent._row_id::text.
  • The HTML is one document of <div class="report-page"> A4 pages. Contract: listen for message {type: "kreport:data", data}, render an initial state from an inline sample constant you embed yourself, call KReport.ready() when rendering settles, and disable chart animations.
  • Verify with test_report, then run_report. A saved run renders live, is shareable, and can be frozen to PDF.

7. K-Jobs: deterministic automation

  • Plain sandboxed JavaScript, no AI inside; capabilities come only from the declared grants (allowed_lw_sheets, allowed_tools, and the rest). The grants ARE the wiring.
  • ctx.lw.read(sheetId) returns all rows in the read shape above; ctx.lw.write/update take the write shapes; write results carry row_ids.
  • The loop: create, dry_run_k_job, diff the captured effects against what you expected to write (decide the expected effects before running; that diff is your test), update_k_job_code, dry-run again, approve.
  • Make mutations idempotent: check an "already done" marker first and return the existing ids on re-run.
  • A widget-processor job's output_schema must deep-equal the widget's input_schema. Author both from one canonical JSON.
  • Beyond ctx.lw.read (full table, small sheets only), jobs have ctx.lw.query (filters/sort/pagination/search), ctx.lw.aggregate, ctx.lw.distinct, ctx.lw.updateByFilter, ctx.lw.batch (the atomic multi-sheet write) and ctx.lw.nextNumber. Reads run for real in dry runs; writes are captured as effects.

8. Pages: the in-app end-user surface

add_page_section, add_page, add_widget_to_page (col_span: 12 for full-row). Place only screen widgets; editor/dialog widgets are "placed" by being granted in an opener's capabilities.widgets or a drawer widget link. Open browser tabs need a reload to pick up newly placed widgets.

9. Debugging playbook

Rule zero: the corrective message IS the fix. Common failures and what they mean:

Symptom Fix
INTERACTIVE_NO_CAPABILITY the html calls a kwidget.* verb with no matching grant. Add it to capabilities (row tools need tools AND lw_sheets)
WIDGET_UPLOAD_NOT_ALLOWED the html has a file input. Call kwidget.uploadFiles from a button instead (section 4)
widget save says the workbook is not linked to the project pass projectid on lw_create_sheet, or call link_component_to_project
WIDGET_CLASS_SHADOWS_QUASAR a widget CSS class redefines a framework utility. Prefix it (.kb-col) in style AND template
HTML_SCRIPT_SYNTAX: Unexpected token '&' && was HTML-escaped inside a <script> block. Scripts take raw &&
a widget list shows only a few rows / stops loading add return_mode: "rows" + page_size to lw_query_rows
SCHEMA_MISMATCH processor output_schema differs from the widget input_schema. Copy one JSON to both
REQUIRED_MISSING / UNIQUE_VIOLATION with field_errors fix the named columns; nothing was saved
REF_NOT_FOUND a ref cell carries a row id that does not exist in the target sheet. Pick real ids
REF_RESTRICT rows are still referenced by a restrict-mode ref. Delete or repoint the referencing rows first
VERSION_CONFLICT your if_version is stale. Re-read the row and retry with its current _version
test_widget_render reports oversized_controls the CSS namespace rule was broken. Find the unprefixed class
NAME_INVALID / NAME_RESERVED / NAME_TAKEN the app name must be a free DNS label (2-40 lowercase letters, digits, hyphens; unique across the platform). Pick another
NO_INDEX_HTML the zip must contain index.html at its root (or under one top-level folder). Zip the build OUTPUT folder, not its parent
SHA_MISMATCH the uploaded file differs from the hash the ticket was minted for. Hash the exact file you upload, mint a new ticket
TICKET_EXPIRED / TICKET_USED upload tickets live 30 minutes and work once. Mint a new one
BUILD_IS_CURRENT you cannot delete the served version. Activate another one first
push reports your bundle hardcodes an app key (warning) a kapp_ literal is in the bundle. Read /kareenos.config.json at boot (section 10.6); the marketplace will not carry a hardcoded build
HANDLER_SQL_READWRITE (warning) a K-API handler carries read-write Data Space SQL. Application data belongs in sheets; keep SQL for compute caches only
the app still serves an old version get_app_hosting_status({probe:true}): the edge re-pulls on the next page load; wait a few seconds, probe again, then redeploy_app
PROJECT_LOCKED / INSUFFICIENT_KAGENT_BALANCE stop and tell the account owner. These need a human
APP_USER_MODE_KAREENOS a handler called ctx.create_app_account or ctx.app_account_user_login on a kareenos_users app. Delete that handler: the built-in _login route signs users in
a route name starting with _ is refused those names are the built-in routes of a kareenos_users app. Pick another name
_login answers 401 INVALID_CREDENTIALS / INVALID_OTP / DEVICE_TOKEN_INVALID the user's Kareenos credentials, OTP code or stored device token are wrong or expired. Show the message; on DEVICE_TOKEN_INVALID drop the stored device token and ask for the password again
_login answers 403 EMAIL_VERIFICATION_REQUIRED / ACCOUNT_NOT_ACTIVATED / NOT_A_MEMBER the user must finish verification or activation in the Kareenos console, or is not a member of this account. The app cannot register anyone
401 on a business route with a kusr_ token the token is not valid for this app (another account, a rotated platform secret, a deactivated account). Drop it and show the sign-in screen

What the user saw. A widget can fail inside the user's browser where no tool call of yours ever shows it: a widget that does not start, a kwidget.* call that was refused, a time-out. Kareenos reports each one to the project log stream. Read them with get_project_logs({projectId, component_type: "widget"}): they are the rows whose message starts with Browser:, naming the widget, the call, the code and the message the user saw. When a user reports a broken widget, read these rows first, and ask the user to repeat the action if there are none yet.

10. Build and host a Kareenos Agentic Web-App

Kareenos hosts your web application at https://<app_name>.kareenos.com and is its entire backend. You build two layers and push one bundle.

10.0 Who signs in: the app's user_mode

Settle this before create_app. It shapes the data model and every handler, so it is the one question worth one line: infer it from what the user said, state it as an assumption with your recommendation, and go.

user_mode Who the users are How they sign in What you write Identity in a handler
third_party_tenants (default) Your OWN customers, members, drivers, partners. They are app accounts, never Kareenos users. They register and sign in through routes you write (ctx.create_app_account, ctx.app_account_user_login); the app holds a kapps_ token. register, login, me, then the business routes. ctx.trigger.app_user = {app_account_userid, app_userid, app_account_id, is_owner}; per-user sheets keyed by app_userid.
kareenos_users Your OWN team: the people who already sign in to the Kareenos console. The same email and password (and Google sign-in, email OTP, trusted devices) as the console, through routes the platform serves under your base URL: _auth_config, _login, _google_login, _me, _logout. The app holds a kusr_ token, which is the user's real Kareenos session. The business routes only. No register or login handler. Route names starting with _ are reserved. ctx.trigger.kareenos_user = {userid, email, fullname, accountid, account_name, is_owner}; the handler runs AS that user; per-user sheets keyed by the Kareenos userid.

Signals: "my customers", "people sign up", "a portal for clients", "a shop" mean third_party_tenants. "For my team", "staff", "an internal tool", "the same login as Kareenos", "extend our Kareenos pages" mean kareenos_users. Every app created before this choice existed is third_party_tenants. update_app({user_mode}) switches a live app and logs every existing session out.

Adding a person to a kareenos_users app is adding them to the Kareenos account (the Teams page, or create_member / invite_members); the app itself never registers anyone.

10.1 One project, two layers, built together

Your application has exactly two layers: the frontend (a single-page app you write) and the backend Kareenos runs for you: K-API routes bound to handler K-Jobs. Build them together, feature by feature, never one ahead of the other:

  1. the sheet(s) the feature needs (lw_create_sheet with projectid);
  2. the handler K-Job (create_k_job, trigger_type: "api_handler", minimal grants, dry_run_k_job, approve_k_job);
  3. the route (create_k_api({app_id, api_name, auth_mode, jobid});
  4. the screen that calls the route;
  5. test_k_api to prove the route, then rebuild and push.

A screen without its route is a mock; a route nobody calls is dead code.

10.2 The handler is the door

The frontend never touches a sheet, an agent or a tool directly. Every read and write is:

screen  ->  POST https://<api-origin>/api/kapi/<app_key>/<route>  ->  handler K-Job  ->  ctx.*

The handler K-Job is the door to the whole ecosystem, and its grants are the keys:

Inside the handler Opened by Use it for
ctx.lw.* allowed_lw_sheets the app's data: query, write, batch, nextNumber, aggregate, distinct
ctx.tools.call(name, args) allowed_tools builtin and partner tools: send an email or a WhatsApp message, geocode an address, write a Google Sheet, take a Stripe payment, generate a QR code
ctx.agents.* allowed_agents_callable a K-Agent answers inside the API call (keep it short; the caller waits at most 30 s)
ctx.intents.fire(name, payload) allowed_intents_published the event bus: publish order.created, let agents and other jobs react asynchronously, answer "accepted"
ctx.jobs.call allowed_jobs_callable delegate to another job
ctx.location.* allowed_location push a courier's position, read the last one, geofences, routes
ctx.vision.* allowed_vision image and video capture sessions
ctx.files, ctx.messages, ctx.notify, ctx.gis, ctx.codes, ctx.crypto, ctx.dedupe, ctx.py granted surfaces media, messaging, push to the owner's team, routing, codes, hashing, idempotency, Python compute
ctx.create_app_account, ctx.app_account_user_login third_party_tenants apps your users' accounts and sessions (refused on a kareenos_users app)

ctx.trigger.app_user (third_party_tenants) or ctx.trigger.kareenos_user (kareenos_users) is the only trusted identity on an authenticated route. Never trust a user id, email or account id sent in the body. Anything that reasons or calls a model is not a K-API handler: enqueue it (an intent, a job call) and answer "accepted".

10.3 Where your app's data lives: Live Workbooks only

Every entity of your app is a sheet in the app's project, keyed by the user key of its mode: app_userid for third_party_tenants, the Kareenos userid for kareenos_users (or a ref column to a users sheet). That is what makes the same data visible to the owner's widgets, reports, dashboards and agents, and to the in-app Super Agent that can extend your application later.

Never create tables outside the Live Workbook ecosystem:

  • no Data Space CREATE TABLE for application data (ctx.sql tables are job-internal compute caches that nothing else on the platform can see; create_k_api warns HANDLER_SQL_READWRITE when a handler carries read-write SQL);
  • no external databases;
  • no browser storage used as a database (localStorage holds the session token and UI preferences only);
  • no files used as a database (ctx.files is for media).

There is no browser-side sheet API by design. If the frontend needs data, it needs a route.

10.4 Register the app

create_app({app_name: "acme-shop", description: "Customer portal for Acme", projectid, user_mode: "third_party_tenants"})
create_app({app_name: "acme-ops",  description: "Field app for Acme's own team", projectid, user_mode: "kareenos_users"})
  • app_name is the subdomain: 2 to 40 lowercase letters, digits and hyphens, unique across the platform, not a reserved word (www, api, login, admin, and similar). The app serves at https://acme-shop.kareenos.com.
  • You receive the app key (public; it is part of every K-API URL) and the app secret (shown once; on a third_party_tenants app it signs your users' session tokens on the server; it must never ship in the bundle, in a job, or in chat again. On a kareenos_users app it signs nothing; keep it anyway).
  • list_apps, update_app (description, rate limit, hosting_enabled, isactive, user_mode), rotate_app_secret (every user is logged out), delete_app (also deletes the app's users and builds).

10.5 The backend: routes and handlers

kareenos_users app. Sign-in is built in; you write the business routes only, all authenticated:

// my_tasks_api (authenticated route) - the handler runs AS the signed-in Kareenos user
const me = ctx.trigger.kareenos_user;            // {userid, email, fullname, accountid, account_name, is_owner}
const rows = await ctx.lw.query(TASKS_SHEET, { filters: [{ col_key: 'a1b2c3', op: 'eq', value: me.userid }] });
ctx.return({ tasks: rows.rows, owner: me.is_owner });

The SPA signs in through the built-in routes under kapi_base (see 10.6 for the client):

POST _auth_config  {}                                            -> {google_signin_enabled, google_client_id, nonce}
POST _login        {email, password}                             -> {otp_required: true, email}      an OTP was mailed (the console's own flow)
POST _login        {email, password, device_token}               -> {token, user, modules, teams}    trusted device: no OTP
POST _login        {email, otp_code, step: "verify_otp",
                    trust_device: true, device_info: {...}}      -> {token, user, modules, teams, device_token, device_expires_at}
POST _google_login {credential}                                  -> {token, user, ...} | 403 NOT_A_MEMBER
POST _me           (Authorization: Bearer kusr_...)              -> {user, modules, teams}
POST _logout       (Authorization: Bearer kusr_...)              -> {logged_out: true}               stateless: drop the token

Store device_token next to the session token and send it with the password on the next sign-in; the user then skips the OTP, exactly as in the console. modules and teams mirror the console's login payload, so the screens can hide what the user may not do; the runtime enforces it regardless.

third_party_tenants app. Nearly every app needs register, login and me, then the business routes.

// register_api (public route)
const r = await ctx.create_app_account(ctx.trigger.app_key, ctx.input.app_userid, ctx.input.password);
if (r.status !== 'success') return ctx.fail(r.message, r.code);   // DUPLICATE_USER -> 409
// extra profile fields go to the project's own sheet, keyed by app_userid
ctx.return({ registered: true });

// login_api (public route)
const r = await ctx.app_account_user_login(ctx.trigger.app_key, ctx.input.app_userid, ctx.input.password);
if (r.status !== 'success') return ctx.fail('Invalid app_userid or password', 'UNAUTHORIZED');
ctx.return({ token: r.data.token });

// me_api (authenticated route)
ctx.return({ user: ctx.trigger.app_user });

// orders_list_api (authenticated route) - always filter by the verified user
const rows = await ctx.lw.query(ORDERS_SHEET, { filters: [{ col_key: 'a1b2c3', op: 'eq', value: ctx.trigger.app_user.app_userid }] });
ctx.return({ orders: rows.rows });

Contract of a handler: name it <route>_api, trigger_type: "api_handler", trigger_config: {}, declare input_schema (required params become a 400 INPUT_SCHEMA for free), grant only what the route needs. ctx.return(value) is the HTTP response (200 {type:"result", data: value}); ctx.fail(message, code) is a 4xx with that code (UNAUTHORIZED/INVALID_CREDENTIALS 401, FORBIDDEN 403, NOT_FOUND 404, DUPLICATE_USER/CONFLICT 409, anything else 400). Normalize the user identifier the same way in register and login (lowercase an email, E.164 a phone); the platform stores it as opaque text.

Bind and prove each route:

create_k_api({app_id, api_name: "login", auth_mode: "public", jobid})            // third_party_tenants only
create_k_api({app_id, api_name: "orders_list", auth_mode: "authenticated", jobid})
test_k_api({api_id, params: {...}, as_app_userid: "jane@example.com"})           // third_party_tenants: a registered user
test_k_api({api_id, params: {...}})                                               // kareenos_users: runs as you
test_k_api({api_id, params: {...}, as_userid: "colleague@acme.com"})              // kareenos_users: as a colleague
list_k_api_logs({app_id})

Never log ctx.input in a register or login handler (the console buffer would carry the password); log field names and outcomes.

10.6 The frontend: Vue 3 + Quasar

The recommended stack is Vue 3 with Quasar v2 (Vite). Scaffold it once:

npm init quasar@latest    # App with Quasar CLI, Vite, Vue 3, SPA, history router

Settings come from the host, never from the bundle. When Kareenos deploys a version it writes /kareenos.config.json at the root of the site: the app key, the app name, the API origin and the ready-made K-API base URL. Your code fetches that file at boot; nothing in the bundle names an app key or an origin. The same zip therefore works under any app, which is what lets the marketplace carry a Web-App and its build without touching it. The file:

{
  "app_key": "kapp_...",
  "app_name": "acme-shop",
  "app_url": "https://acme-shop.kareenos.com",
  "api_base": "https://<api-origin>",
  "kapi_base": "https://<api-origin>/api/kapi/kapp_.../",
  "hosting_domain": "kareenos.com",
  "user_mode": "third_party_tenants",
  "builtin_auth": null,
  "version": "1.0.0",
  "generated_at": "2026-09-12T10:00:00.000Z"
}

On a kareenos_users app user_mode is "kareenos_users" and builtin_auth is {"auth_config": "_auth_config", "login": "_login", "google_login": "_google_login", "me": "_me", "logout": "_logout"}. The client module below reads user_mode to decide which sign-in screen to show.

For quasar dev you need the same file locally: call get_app_web_config({app_id}) once after create_app and write data.config to public/kareenos.config.json. It is byte for byte what the host generates. quasar build copies it into dist/spa, the zip carries it, and the host overwrites it with the generated file on every deploy. Never read these values from anywhere else and never hardcode them: the push reports a warning when the bundle contains an app key literal, and the marketplace will not carry such a build.

One API client module is the whole backend integration:

// src/services/kareenos.js
let config = null
const ready = fetch('/kareenos.config.json', { cache: 'no-cache' })
  .then(r => { if (!r.ok) throw new Error('kareenos.config.json ' + r.status); return r.json() })
  .then(c => { config = c; return c })

export function getConfig () { return ready }        // resolves { app_key, kapi_base, user_mode, builtin_auth, ... }
export function getToken () { return localStorage.getItem('kapi_token') }
export function setToken (t) { t ? localStorage.setItem('kapi_token', t) : localStorage.removeItem('kapi_token') }
export function getDeviceToken () { return localStorage.getItem('kapi_device_token') }     // kareenos_users: trusted device
export function setDeviceToken (t) { t ? localStorage.setItem('kapi_device_token', t) : localStorage.removeItem('kapi_device_token') }

export async function call (route, params = {}) {
  const c = config || await ready
  const headers = { 'Content-Type': 'application/json' }
  const token = getToken()
  if (token) headers.Authorization = `Bearer ${token}`
  const res = await fetch(c.kapi_base + route, { method: 'POST', headers, body: JSON.stringify(params) })
  const body = await res.json()                    // {type:'result', data} | {type:'error', value, text}
  if (res.status === 401) { setToken(null); throw Object.assign(new Error(body.text || 'Please sign in'), { code: 'UNAUTHORIZED', unauthenticated: true }) }
  if (body.type !== 'result') throw Object.assign(new Error(body.text || 'Request failed'), { code: body.value, status: res.status })
  return body.data
}

// third_party_tenants usage
// await call('register', { app_userid: email, password })
// setToken((await call('login', { app_userid: email, password })).token)
// const { orders } = await call('orders_list')

// kareenos_users usage (the built-in routes; names come from config.builtin_auth)
// const c = await getConfig()
// const step1 = await call(c.builtin_auth.login, { email, password, device_token: getDeviceToken() || undefined })
// if (step1.otp_required) {            // the console's email OTP: ask the user for the code
//   const s = await call(c.builtin_auth.login, { email, otp_code, step: 'verify_otp', trust_device: true, device_info: { app: c.app_name } })
//   setToken(s.token); setDeviceToken(s.device_token)
// } else { setToken(step1.token) }     // trusted device: signed in at once
// const { user, modules } = await call(c.builtin_auth.me)

On a kareenos_users app a DEVICE_TOKEN_INVALID error means the stored device token expired: call setDeviceToken(null) and ask for the password again. Never build a register screen for such an app; members are added in the Kareenos console.

This works with quasar build as it is: no top-level await (the default build target does not allow it) and no boot file is required, because the fetch starts when the module loads and every call awaits it. If a screen needs a value before its first render (the app name in a header), add a boot file src/boot/kareenos.js with export default async () => { await getConfig() } and register it in quasar.config.js; the whole app then mounts after the config has loaded.

Rules: the app secret is never in the bundle; keep the token in localStorage and drop it on 401; use the history-mode router (the host falls back to index.html); CORS to the K-API origin is already open, nothing to configure; no app key and no API origin anywhere under src/: /kareenos.config.json is the only source of settings.

10.6b Migrating an app built before the config file

An app whose bundle still hardcodes its key keeps working, but the marketplace will not carry its build. To migrate it, nothing changes on the platform side:

  1. get_app_web_config({app_id}) and write data.config to public/kareenos.config.json.
  2. Replace the hardcoded APP_KEY / API_ORIGIN / BASE in src/services/kareenos.js with the module above. The call() surface is unchanged, so no screen needs editing.
  3. grep -rn "kapp_" src/ must be empty (and no API origin under src/ either).
  4. Build, zip and push as usual (a new version, or the same one to replace it). The push response must show warnings: []; if it names a file with a key literal, fix it and push again.
  5. curl https://<app_name>.kareenos.com/kareenos.config.json shows the key; sign in through the app to confirm the calls go through kapi_base. Publish only after that.

10.7 Build, zip, push, verify

quasar build                                        # -> dist/spa
cd dist/spa && zip -r ../../acme-shop-1.0.0.zip . && cd ../..   # index.html at the zip root
shasum -a 256 acme-shop-1.0.0.zip                   # or: sha256sum acme-shop-1.0.0.zip

Then, over MCP:

request_app_build_upload({app_id, version: "1.0.0", sha256: "<the hash>"})

It returns a single-use upload ticket (30 minutes) and the exact curl command. Run that curl from the folder that holds the zip; your MCP key never enters the shell. The push goes live by default (pass activate: false to stage it). Then:

get_app_hosting_status({app_id, probe: true})       # in_sync: true  ->  open https://acme-shop.kareenos.com

__MACOSX folders and .DS_Store files inside the zip are ignored. The bundle must contain index.html at its root or under one top-level folder; no symlinks, no ZIP64, no encryption; at most 50 MB zipped, 300 MB unpacked, 20,000 files.

10.8 Versions, rollback, redeploy

  • list_app_builds({app_id}): every pushed version, is_current marks the served one.
  • Re-pushing the same version label replaces it; bump the label for a new release.
  • activate_app_build({app_id, version}): switch the served version. This is the rollback.
  • redeploy_app({app_id}): force the edge to re-download and re-extract the current build.
  • delete_app_build({app_id, build_id}): never the served one.
  • The owner sees the same versions in Settings, Apps & K-APIs, Versions, and can upload a zip by hand there.

The edge re-checks what to serve on page navigations, so a new version lands within seconds; assets never switch under a running session.

10.8b Publishing your Web-App to the marketplace

A project that contains a Web-App publishes it with the project: the app, its routes and the current version travel as one unit. Whoever imports the listing chooses a new name for the app (the name is its address), receives a new app key and secret, and gets the build copied and live at the new address. Because the bundle reads /kareenos.config.json, the same zip works for them without any change; a bundle that hardcodes an app key is excluded from the listing. The importer's routes answer 503 until they approve the handler jobs (one consent and one call with approve_app_handlers, section 10.11), and your users are never copied. If you open-source the frontend, declare the repository on the app (update_app({app_id, source_repo_url})): the listing and the import result then tell every importer where the source lives and what to say to Claude Code (section 10.12). Later revisions never overwrite a build the importer pushed themselves: your new build reaches them as a staged version they may activate.

10.9 What the host does and does not do

It serves static single-page applications: index.html is never cached, hashed assets are cached for a year, unknown paths without a file extension fall back to index.html, missing files answer 404. It does not run server code, does not render on the server, does not serve custom domains (v1), and does not serve /.well-known. It writes /kareenos.config.json on every deploy and serves it uncached; a copy inside the zip is replaced. Secrets do not belong in a bundle: the browser holds only the user's session token and reads the public settings from that file.

10.10 Your source folder: what is committed, what stays local

A Web-App source folder is linked to exactly one app of one account. The link is two small files that are never committed; everything that is committed carries no account data. This is what lets anybody fork a frontend and point it at their own copy of the backend.

File Git Content
kareenos.source.json committed The account-neutral manifest: kareenos_source_format, name, version, user_mode, routes, and origin (app_id, project_id, listing_id): the app this source was published from. origin is what resolves a fork to the importer's copy. It is opaque and useless outside your account, and less sensitive than the app key every visitor already sees.
CLAUDE.md section committed Tells every Claude Code session: ids for YOUR account come from .kareenos/link.json and link_app_source; ids anywhere else may be the publisher's; when the folder is not linked yet, call link_app_source first.
.mcp.json committed The connection without a secret: url ${KAREENOS_MCP_URL:-https://<your deployment>/mcp}, header Authorization: Bearer ${KAREENOS_MCP_KEY}. Every account brings its own key.
.kareenos/link.json ignored Which project and app of THIS account the folder is linked to, and where it was imported from.
public/kareenos.config.json ignored The development copy of the runtime settings (section 10.6).
Knowledge docs committed Contracts, gotchas, conventions. They name components, they never quote ids.

Three files have similar names and different jobs. Do not confuse them:

File Job
kareenos.json The manifest of a marketplace PACKAGE repository (the "Export to GitHub" of a listing).
kareenos.source.json The manifest of a frontend SOURCE repository (this section).
kareenos.config.json The runtime settings the host writes on every deploy.

The platform is the id ledger. link_app_source({app_id, detail:"full"}) returns, at any time and always current: every route with its handler job and approval state, the jobs, the workbooks and sheets, the agents. Nothing of that belongs in a committed file.

10.11 Continue an imported Web-App from its source

You imported a project from the marketplace and you hold a fork or a clone of its open-source frontend. Importing minted new ids for everything: the project, the app (a new name, so a new address, and a new app key), every route, handler job, sheet, workbook and agent. Inside your account the import already rewrote every reference. The one place the publisher's ids survive is the folder. Until you link it, the ids in it belong to someone else's account, and quasar dev would talk to the publisher's live app.

Connect Claude Code with a Tools-mode connection on the imported project (the default Agents mode carries no builder tools), open the folder and say:

link this project to my imported project

What happens, and what you can check:

  1. One call. Claude Code reads the ids it finds in the folder (kareenos.source.json origin, or a few ids from an older LEDGER.md) and the route names the frontend calls, then calls link_app_source({origin_ids, route_names, origin_listing_id}). Your app is found through the id map your import recorded, never by name. With nothing to pass, the connection's default project decides. If several apps qualify you are asked once which one.
  2. Two files. data.link goes to .kareenos/link.json, data.web_config to public/kareenos.config.json (always overwritten). Both are git-ignored; in a fork the ignore lines go to .git/info/exclude, and a tracked config file gets git update-index --skip-worktree public/kareenos.config.json. No tracked file is edited. git status stays clean and merging the publisher's later work never conflicts on ids.
  3. One summary. data.checks is everything an import leaves open, recomputed from the live state of your account: routes that answer 503 (their handler is unapproved, disabled or missing), the jobs those handlers call, routes the source calls that your app does not have, Agent Config keys that are empty (values never travel with a listing: you provide them), draft agents, disabled integrations, grants that point nowhere, and whether a newer revision of the listing is available.
  4. One consent. approve_app_handlers({app_id}) previews exactly the jobs your routes need: the handlers plus the jobs they call. You answer one yes or no; on yes the same tool is called with confirm_job_count and approves that list, recorded in your name. Scheduled and event-driven jobs are deliberately not part of it, because they start running the moment they are approved: each is explained and approved on its own.
  5. Proof. test_k_api on one route answers 200. From there the loop of sections 10.5 to 10.8 applies unchanged. Push under a NEW version label, never the label of the build you imported: that row is your rollback copy of the publisher's frontend. On a localhost development origin sign in with email and one-time code.

Later, the publisher's work reaches you through two channels. Backend changes (routes, handlers, sheets) arrive as a marketplace update that you review and apply in the console (Projects, the update banner on the imported project, Review update). It never overwrites a build you pushed: the listing's build lands as a staged version. Frontend changes arrive through git: merge upstream, build, push. After either, say "link again": new handlers land unapproved and new routes may be missing. If the source calls routes your app lacks and no update is available, the source is ahead of what you imported: check out the tag of the imported build, or build the missing handlers.

Code Meaning Fix
linked:false, AMBIGUOUS_APP / AMBIGUOUS_PROJECT Several apps qualify. Pick one; the call is repeated with app_id.
NOT_IMPORTED The ids in the folder belong to no import of this account and the app lacks most routes the source calls. Import the listing first (the message carries its address).
NOT_FOUND on app_id The id came from the repository (it is the publisher's), or the link is stale (a restored project, another account). Link again without app_id.
NO_APP_IN_PROJECT / NO_APP_IN_ACCOUNT Nothing to link to. Import the listing, or build the app (section 10.4).
COUNT_CHANGED The list of jobs changed between the preview and your yes. Review the new preview.

10.12 Open-source your frontend

Run /kareenos:kareenos_open_source_app, or say "prepare this project for open source". The result is a repository anybody can fork and link with one sentence:

  1. link_app_source({app_id, detail:"full"}) on your own app: data.manifest becomes kareenos.source.json, the two link files are written, nothing else.
  2. .gitignore lists .kareenos/, public/kareenos.config.json, dist, *.zip, .env*. A config file that was ever committed is removed from the index (git rm --cached public/kareenos.config.json), otherwise every fork develops against your live app.
  3. Account data leaves the tracked files: id tables, the app key, the API host, the app address, log ids and sample row ids. The knowledge stays: contracts, gotchas, conventions, naming components and never ids.
  4. CLAUDE.md, .mcp.json and a README section "Run this app on your own Kareenos account": import the listing, fork, connect Claude Code in Tools mode on the imported project, say "link this project to my imported project".
  5. A scrub check over the tracked files (git ls-files): no UUID, no kapp_, kmcp2_, kmcp_, ksec_ or kaup_ literal, no app address and no API host, with the single exception of origin in kareenos.source.json.
  6. Versions line up: package.json version, kareenos.source.json version, the pushed build label and the git tag v<label>; the commit id goes into the build notes.
  7. update_app({app_id, source_repo_url}), then publish a new revision of the listing.

The marketplace "Export to GitHub" of a listing is a different thing: it publishes the importable PACKAGE, and it replaces the whole tree of its target repository on every push. It must never point at your source repository; the platform refuses it when it does.

11. Before you declare any build done

  • Workbooks linked to the project before any widget/action was authored.
  • Every widget: test_widget clean, and test_widget_render clean where available.
  • Every widget CSS class prefixed; every kwidget.* verb granted; return_mode:"rows" on every widget-side query.
  • After the user tried each interactive widget in the browser, get_project_logs({projectId, component_type: "widget"}) shows no Browser: error.
  • Jobs dry-run-diffed before approval; mutations idempotent.
  • Reports verified with a real run.
  • Screen widgets on Pages; editors referenced by their openers.
  • For a hosted app: user_mode settled and stated before create_app; every screen has its route, every route its handler and a test_k_api proof; all app data in sheets, keyed by the mode's user key; the secret nowhere in the bundle; no app key or API origin hardcoded (the client module reads /kareenos.config.json and the push reported no warning); get_app_hosting_status({probe:true}) reports in_sync: true; the real sign-in (register and login, or the built-in _login with a Kareenos account) and business flows tested in the browser at the app URL.
  • A handover document with the full id ledger (app URL, app key, routes, sheet ids, job ids, the current version) and remaining manual steps.
  • For a Web-App source folder: linked (.kareenos/link.json and public/kareenos.config.json present and git-ignored), kareenos.source.json committed, no id, key or host in any tracked file (section 10.10).
  • The Agentic Operations Phase offered once (section 12): on yes, one handoff_to_super_agent call with a briefing that holds no secret, and the link opened for the user.

12. The last phase: Agentic Operations

A working app is not the end of the build. The people who run the business know their business, not the agentic vocabulary, so they rarely know which AI Agents to ask for. The Agentic Operations Phase closes that gap: the in-app Super Agent studies the live operation you built, proposes the AI Agents that could run it with the team, and builds and wires the ones the user picks.

What you build, and what the Super Agent builds. You build the app, the data and the automation, plus any agent that is part of an app feature the user asked for (for example the assistant a screen calls). The operation's agent team (the agents that watch for changes, message customers and suppliers, and escalate to people) is designed in this phase, grounded in live data. Do not invent it yourself.

When to offer it. Once, when the build works: for a Web-App, its routes answer 200, its handlers are approved and hosting is in sync; for a Business Project, get_solution_readiness is clean. One line, recommending yes, for example: "Your app is live. The last step is the Agentic Operations Phase: the Kareenos Super Agent studies what we built, proposes the AI Agents that can run it with your team, and builds the ones you pick. Shall I open it in your browser?" If the user declines, /kareenos:kareenos_agentic_operations starts it later.

The briefing. Write 800 to 24,000 characters of prose and bullet lists (no JSON), using the names the components have in Kareenos. Draw on the conversation, the folder's notes (PLAN.md, LEDGER.md, README) and, when useful, get_operation_map({projectid}). Eight sections:

  1. The business: what it does, its customers, suppliers, team roles, volumes, languages, working hours.
  2. What was built: the Web-App (who uses it, its main screens), each workbook and sheet and what it holds, each automated tool (K-Job) and when it runs, any existing agents.
  3. How work flows today: the core loop, step by step, who does what.
  4. Where people still notice something and react by hand.
  5. Channels and contact fields: what exists and what the user wants.
  6. Decisions and deferred items.
  7. Open items: unapproved jobs, empty Agent Config keys, known gaps.
  8. What the user hopes AI Agents will do.

Never include keys, passwords, tokens, hashes or customers' personal data: describe fields, not values. The public app key is fine. A secret-looking line refuses the call (BRIEFING_HAS_SECRET) and nothing is stored.

The call. handoff_to_super_agent({briefing}); projectid defaults to the connection's project. It returns data.url, a single-use link to New Task, the Super Agent's page in level 1 of Kareenos. Open it for the user (macOS open "<url>", Linux xdg-open "<url>", Windows start "" "<url>"); if you cannot run commands, show it.

What the user sees. Kareenos opens on New Task in level 1, with the project selected (after sign-in if needed), and the Super Agent starts with your briefing. Level 1 is where the agents this phase builds live, beside the team's Inbox and Teams. The Super Agent reads the live project in one call, shows how the operation runs, and proposes at most four AI Agents: for each, what it watches, what it decides, what it does, which channel it speaks on, who it escalates to, and which of the user's own sheets and jobs it rests on. It is honest about what blocks an agent (no contact field, no channel, nobody to escalate to) and says what would have to exist first. The user picks in one form; the Super Agent then builds and wires the chosen agents, runs the loop once and shows what is live.

The link. It runs once: opened again, it fills the message box instead of sending twice. It expires in 7 days, a newer handoff for the same project replaces an unopened older one, and it opens only for users of the account who may use the Super Agent. A link written through a colleague's connection arrives prefilled for review, never sent automatically.

Errors. BRIEFING_TOO_SHORT or BRIEFING_TOO_LONG (rewrite to the eight sections), BRIEFING_HAS_SECRET (remove the named lines), NOT_FOUND (pass a projectid from get_k_projects), HANDOFF_LIMIT (30 per account per day: reuse the latest link), MIGRATION_PENDING or CONSOLE_URL_UNCONFIGURED (the user opens New Task in Kareenos, picks the project in the top bar and asks "What AI Agents could run this?").

After the handoff. You stay the app builder. When the Super Agent adds agents the Web-App should call, wire them through the handler K-Jobs (ctx.agents.call) when the user asks. Values the new agents need (API keys and similar): ask the user and store them with set_agent_config (you see whether a key is set, never its value), or the user enters them in Settings, Agent Config.

Running it again. The user can ask the Super Agent "what agents could run this?" in New Task at any time. While a project has data sheets and no agent yet, the same question sits under AI Agents in the level 1 sidebar; builders also find it on the Overview of the Projects page (Advanced). A later audit remembers the earlier one: it leads with what changed since, and it never re-proposes what the user declined without new evidence.