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:

  1. kareenos_context() then kareenos_context({topic:"web_apps"}) (the doctrine and the loop).
  2. create_app({app_name, description, projectid}): 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.

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. 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, 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): 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.

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.

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

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 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
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

10. Build and host a full web application

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.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 always your users' accounts and sessions

ctx.trigger.app_user 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 app_userid (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})
  • 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; it signs your users' session tokens on the server and must never ship in the bundle, in a job, or in chat again).
  • list_apps, update_app (description, rate limit, hosting_enabled, isactive), 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

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

One API client module is the whole backend integration:

// src/services/kareenos.js
const API_ORIGIN = 'https://<api-origin>'          // the Kareenos API origin
const APP_KEY    = 'kapp_…'                       // public: fine in the bundle
const BASE       = `${API_ORIGIN}/api/kapi/${APP_KEY}/`

export function getToken () { return localStorage.getItem('kapi_token') }
export function setToken (t) { t ? localStorage.setItem('kapi_token', t) : localStorage.removeItem('kapi_token') }

export async function call (route, params = {}) {
  const headers = { 'Content-Type': 'application/json' }
  const token = getToken()
  if (token) headers.Authorization = `Bearer ${token}`
  const res = await fetch(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
}

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

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; put the API origin and app key in a config file per environment, not scattered across components.

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.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. Secrets do not belong in a bundle: the browser holds only the app key and the user's session token.

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.
  • 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: every screen has its route, every route its handler and a test_k_api proof; all app data in sheets; the secret nowhere in the bundle; get_app_hosting_status({probe:true}) reports in_sync: true; the real register, login 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.