A local development environment for Kizen plugin apps.
appbuilder scaffolds a new plugin, bundles it, and runs a live viewer in a dedicated Chromium window so you can iterate on your plugin against any Kizen environment without having to publish, deploy, or reload by hand.
| Requirement | Version | Why |
|---|---|---|
| Node.js | >=20 |
Enforced by engines in package.json. |
| Google Chrome or Chromium | any recent release | The viewer is launched via chrome-launcher against a locally installed browser. No browser is bundled. |
| Python | 3.12 or newer | Only needed to run code steps locally and for appbuilder admin. For code steps, the dev server builds a virtualenv with the interpreter the step's runtime asks for (python-3-12 / python-3-13), matching the runtime images the hosted Kizen code-runner ships. appbuilder admin uses the newest Python 3.12 or newer it finds. |
chrome-launcher auto-discovers an installed Chrome/Chromium; if yours lives somewhere unusual, set CHROME_PATH to the executable and it will be preferred.
Python is resolved lazily — the CLI only looks for an interpreter the first time a plugin actually runs a code step, or the first time you run appbuilder admin, so you can build UI-only plugins without it.
Run it without installing:
npx @kizenapps/cli devOr install it globally. The published binary is named appbuilder:
npm install -g @kizenapps/cli
appbuilder devEvery commit to main publishes a prerelease under the next dist-tag (versioned <version>-<short-sha>); tagged releases go to latest. To pick up an unreleased fix:
npm install -g @kizenapps/cli@nextappbuilder createThe wizard first asks where the plugin should live (the current directory, or a new sub-directory named after the API name), then collects five fields:
| Field | Required | Notes |
|---|---|---|
| Name | yes | Human-readable plugin name. |
| API name | yes | Defaults to a snake_cased version of the name. Hyphens are rejected by the platform. |
| External link | no | Documentation or marketing URL for the plugin. |
| Description | no* | See the note below. |
| Business ID | no* | Prefilled from your appbuilder login session's business when you're signed in, else from your stored API-key credentials. |
Fill in Description and Business ID. Both are labelled optional in the wizard, but
createwrites them intokizen.jsonas empty strings and the bundler rejects an emptydescriptionordeveloper_business_id— so a plugin created with those fields skipped failsappbuilder builduntil you editkizen.jsonby hand. This is tracked internally (KZN-17594); until that fix lands, treat both as required.
It then asks which artifacts to scaffold — Floating frame, Block, Data adornment, Routable page, Toolbar item, Object settings item, JS action — with all seven selected by default (Space toggles, a selects all, n none). Each selected type gets a working hello* directory under src/, holding a config.json and a script that runs as-is.
create writes kizen.json, src/, releaseNotes/, a placeholder src/thumbnail.png (512×512, colored from the api_name), the templates for the artifacts you picked, and the Copilot review files described under appbuilder create. It adds both .kizenapp/ and .copilot-docs/ to .gitignore.
API-key credentials are deprecated for block commands.
appbuilder block pushandappbuilder block targetsuse yourappbuilder loginsession by default.appbuilder devandappbuilder encryptstill use API keys:encryptuntil Plugin Wizard accepts sign-in sessions, and OAuth fordevis planned. The rest of this section applies to them.
appbuilder dev talks to a real Kizen environment on your behalf, so it needs four things:
| Field | Where it comes from |
|---|---|
| API Key | An API key issued for your Kizen user, from the Kizen app. |
| User ID | The UUID of your Kizen user. |
| Business ID | The UUID of the Kizen business you are developing against. |
| Environment | One of go, fmo, staging, integration, test1 — which Kizen deployment the above belong to. |
They are sent as X-API-KEY / X-USER-ID / X-BUSINESS-ID headers on every proxied request, so they must all belong to the same environment.
On the first appbuilder dev in a plugin directory you are prompted to pick a credential mode:
- Global — credentials are written to
~/.kizenappbuilder/credentials.json(directory0700, file0600) and shared across every plugin on the machine. You can keep additional named profiles alongside it as~/.kizenappbuilder/<profile>.jsonand switch between them withcin the TUI. - Local — nothing is written to disk by the CLI; you enter credentials inside the viewer, and they live in the browser profile under
.kizenapp/.
The choice and the active profile name are remembered in .kizenapp/config.json, so subsequent runs load silently. --credentials <path> bypasses all of this and reads a specific JSON file.
cd my-plugin
appbuilder devThis builds the plugin, starts the local server on port 3121, and opens the viewer in a dedicated Chromium window. Every file change rebuilds and hot-reloads; if validation fails, the error appears in the TUI and the viewer keeps the last good bundle.
TUI keys: v launches the viewer (useful with --no-viewer), c switches credential profile, q quits.
appbuilder buildWrites .kizenapp/bundle.json — the same artifact dev serves — after running the full validation pass. Use this in CI or whenever you want a bundle without starting a server.
Scaffolds a new Kizen plugin project. Interactive by default; passing any flag other than --include-lib switches it to a non-interactive run, including --artifacts to choose the artifact templates up front (appbuilder create --help lists them all). Pass --include-lib to also write the Kizen data helper library to src/lib/kizenData.js; without it, no library is written. --business-id and --environment default from your appbuilder login session: the environment is -e, else the only environment you're signed in to, else go, and the business is that environment's session business. Only when you're signed out entirely does it fall back to ~/.kizenappbuilder/credentials.json. See Quickstart for the fields it collects, the artifact picker, and everything it writes.
The scaffold also includes .github/copilot-instructions.md, two path-scoped instruction files under .github/instructions/, and .github/workflows/copilot-code-review.yml. That workflow fetches the Kizen plugin docs from kizen/app-engine into .copilot-docs/ before Copilot code review runs, so reviews cite the current documentation instead of rules copied into the plugin repo. It only takes effect once it is on the repository's default branch, and .copilot-docs/ is gitignored. Plugins scaffolded before this setup existed can pick it up with appbuilder setup-copilot.
Writes the same Copilot review files into an existing plugin repo — run it from the repo root (a kizen.json must be there). The four files are CLI-owned, so the command overwrites them and prints created, updated, or unchanged per file; a clobbered local customization is recoverable from git. It also adds .kizenapp/ and .copilot-docs/ to .gitignore if they are missing.
| Flag | Default | Purpose |
|---|---|---|
--dry-run |
off | Print the per-file statuses without writing any |
As with create, the workflow only runs once it has reached the repository's default branch.
Installs or refreshes the Claude Code skill for building custom blocks (.claude/skills/kizen-custom-block/SKILL.md) in the plugin in the current directory. appbuilder create writes the skill once; run setup-claude in an existing plugin to add it, or after upgrading the CLI to pick up the bundled version. With --mcp it only registers the Kizen MCP server instead and writes no files, so it can run in any directory (see below).
appbuilder setup-claude [--dry-run] [--include-lib]
appbuilder setup-claude --mcp [--env <env>] [--force] [--dry-run]| Flag | Default | Purpose |
|---|---|---|
--dry-run |
off | Report what would change without writing files. |
--include-lib |
off | Also install the Kizen data helper library (kizenData.js). |
--mcp |
off | Only register the Kizen MCP server in Claude Code (user scope); writes no files, so it works in any directory. |
--env <env> |
go |
Environment of the MCP server to register. |
--force |
off | Replace an existing registration of that server. |
--env and --force only apply with --mcp, and --mcp can't be combined with --include-lib; see Using the session from Claude Code.
It also installs the skill's design guide (.claude/skills/kizen-custom-block/design.md), which tells the agent how to make blocks match native dashlets. With --include-lib, it installs the Kizen data helper library that block scripts import to read records and apply the dashboard's date and team filters. The library goes under the entry directory from kizen.json (<entry>/lib/kizenData.js, one per entry in a multi-plugin kizen.json), and under src/ when kizen.json has no usable entry. Without the flag, it refreshes only the libraries that already exist. appbuilder create --include-lib writes it to src/lib/kizenData.js.
It prints one line per file (created, updated or unchanged) and a one-line summary. These files are managed by the CLI, so local edits to them, including edits to kizenData.js, are replaced when they're updated. Don't edit kizenData.js; put your own helpers in another file under src/lib/. It only writes these files and never touches the .github/ Copilot files or block code. Without --mcp, it fails with exit code 1 when there's no kizen.json in the current directory.
block push adds the warning Claude files managed by appbuilder are out of date; run appbuilder setup-claude when the plugin has the skill and the skill or its design guide is missing or differs from the bundled one, or an existing kizenData.js differs from the bundled one. The warning never fails the push.
Reads the plugin in the current directory, validates it against the same rules enforced by the Kizen platform and Plugin Wizard, minifies sources, and writes .kizenapp/bundle.json. No flags.
If validation finds any errors (for example an api_name containing hyphens, which the platform rejects) the build fails and prints each issue grouped by file. Fix the reported issues and re-run.
Automation step configs are validated the same way — parameter data types, secrets that must be declared in the manifest's base_config.secrets, fields removed from the publish contract, and runtime — against the list of automation data types bundled in @kizenapps/packager. The Plugin Wizard applies the same rules at publish, against the live list read from the target environment, so a build that passes locally is expected to publish clean.
Starts the dev server and opens the viewer. Watches your plugin directory and rebuilds + hot-reloads the viewer on every change. Each rebuild runs the same validation as build.
| Flag | Default | Purpose |
|---|---|---|
-p, --port <port> |
3121 |
Port the local dev server listens on |
-c, --credentials <path> |
— | Use a specific credentials JSON file instead of a stored profile |
-d, --debug |
off | Show a CDP event panel in the TUI |
-v, --verbose |
off | Log every CDP event and handled error (implies --debug) |
--no-viewer |
— | Don't auto-launch the viewer on startup (press v to launch it) |
--no-cache |
— | Disable the network proxy cache (always fetch upstream) |
The viewer and the proxy cache are both on by default; the two --no-* flags turn them off.
Encrypts a secret against a plugin's encryption keys and prints the envelope you paste into a kizen.json secret value:
{ "encrypted": true, "value": "<base64>" }The command talks to the Plugin Wizard host directly — appbuilder dev does not need to be running. It still uses API-key credentials (deprecated for block commands) until Plugin Wizard accepts sign-in sessions.
| Flag | Default | Purpose |
|---|---|---|
-c, --credentials <path> |
global credentials | Path to a credentials JSON file |
-a, --api-name <name> |
api_name from kizen.json in the cwd |
Plugin the secret belongs to |
-v, --value <value> |
— | Plaintext secret. Prefer piping on stdin — a flag value is visible in ps. |
-s, --stage <dev|prod> |
prod |
Which encryption API to use. Defaulting is announced on stderr. |
--remote |
off (encrypt locally) | Have the wizard's /encrypt endpoint do the crypto with the keypair it holds, instead of fetching the public key and encrypting in-process. |
-o, --out <path> |
— | Also write the envelope to a file as pretty JSON |
Interactive by default. When either stdin or stdout is not a TTY (CI, a redirect, a pipe) it switches to a headless flow: everything must come from flags, the secret may be piped in, and the compact single-line envelope goes to stdout with all diagnostics on stderr.
printf %s "$SECRET" | appbuilder encrypt -a my_plugin -s prod > secret.jsonA failed --out write is a non-fatal warning — stdout already carries the envelope — but sets a non-zero exit code.
Generates a self-contained, browsable report of the plugin in the current directory: the kizen.json config, a file tree, and every source file. Two files are written — an HTML report and a Markdown one (same path with a .md extension), the latter being useful as LLM context.
| Flag | Default | Purpose |
|---|---|---|
-o, --output <path> |
~/.kizenappbuilder/examples/<api_name>.html |
Output file path |
developer_business_id is stripped and every service's auth_credentials is redacted before rendering, so a report is safe to share.
Prints every valid icon name accepted by toolbar items, pages, and adornments, one per line. No flags — pipe it to a pager or grep it.
appbuilder icons | grep calendarPackages the plugin in the current directory (same validation and minification as build) and prints one block as pretty JSON — exactly what the Custom Block (AI Coded) dashlet's paste editor accepts. Nothing is written to .kizenapp.
appbuilder block export [api_name] [--copy]| Argument / flag | Default | Purpose |
|---|---|---|
[api_name] |
the plugin's only block | Block to export. Required when the plugin has more than one block. |
--copy |
off | Also copy the JSON to the clipboard; a one-line status goes to stderr. |
Only the block JSON goes to stdout, so it can be piped or redirected (appbuilder block export > block.json). Validation warnings and errors go to stderr. The command fails with a non-zero exit code if validation fails, the block can't be found or is ambiguous, or the packaged block would be rejected by the paste editor (for example a block with no script.js, a non-positive-integer min_w, or a min_w greater than its max_w).
The JSON carries only the sizes the block's config.json writes: min_w, max_w, min_h, max_h, default_w and default_h. Sizes it leaves out are left out of the JSON too, rather than filled with packager defaults. default_w and default_h are the starting size, in grid columns and rows, of a dashlet created from the block. Each size that is set must be a positive integer, each min_* must be no greater than its max_*, and each default_* must fall within whichever of its min_* and max_* are set (for example min_w ≤ default_w ≤ max_w). Otherwise the export fails as invalid_block naming the field.
If the plugin has components in src/views/, the JSON also carries a views array: each packaged view's api_name, name, type, script/html/css (each left out when empty) and event_scripts (as a [{ name, script }] array), so the block can open one with this.showViewInModal('<api_name>'). Components in src/pages/ are not included. Without views, the output is unchanged.
Packages the plugin in the current directory (same validation as block export) and writes one block into a Custom Block (AI Coded) dashlet on a Kizen dashboard, homepage or chart group: it updates an existing dashlet or creates a new one. Card chrome (background, border, radius, shadow) comes from the dashboard's style settings, like native dashlets.
appbuilder block push [api_name] [flags]Interactive by default: pick the environment (only when you pass no --env and you're signed in to several, or the account you name is saved in several), the block, the surface (dashboard, homepage or chart group), the dashboard, then whether to update an existing custom code dashlet or create a new one, and confirm. Pushing to a production environment (go, fmo) asks you to type y. In a TTY, --yes skips the confirm prompt only when the headless write gate would apply, so go and fmo still ask for a typed y unless --allow-production is also passed; --dry-run shows the plan without writing. A dashlet edited in Kizen since the last push still prompts before overwriting unless --force is passed.
It runs headless when stdin or stdout is not a TTY, or when --json is passed. --yes never selects the mode. Headless runs never prompt: anything that would be a question fails with needs_choice and lists the choices.
| Argument / flag | Default | Purpose |
|---|---|---|
[api_name] |
the plugin's only block | Block to push. Required when the plugin has more than one block. |
-c, --credentials <path> |
the sign-in session | API key (deprecated): credentials JSON file to use instead of the session |
--profile <name> |
the sign-in session | API key (deprecated): stored credential profile to use instead of the session |
--dashboard <id> |
the remembered target | Dashboard, homepage or chart group to push to |
--dashlet <id> |
matched by push history or name | Existing custom code dashlet to update. Needs --dashboard; can't be used with --create. |
--create |
off | Create a new dashlet instead of updating one |
--dry-run |
off | Resolve everything and print the request without writing |
--yes |
off | Write without asking. Headless runs without it are dry runs. |
--allow-production |
off | Allow headless writes to go / fmo |
--force |
off | Overwrite a dashlet that was edited in Kizen since the last push |
--forget |
off | Clear the remembered target for this block before resolving |
--json |
off | Print a JSON result to stdout (forces headless); the human summary goes to stderr |
--env <env> |
the only signed-in environment, or with --business the only one where it's saved |
Environment of the session to use. Not with -c or --profile. |
--business <id or name> |
$APPBUILDER_BUSINESS, else the current account |
Saved account to use, matched as for login --business. Not with -c or --profile. |
--user <user id> |
$APPBUILDER_USER |
With --business, the user id of that saved account. Not with -c or --profile. |
Credentials. Without -c or --profile, the push uses your appbuilder login session for --env, or for the only environment you're signed in to. If you're signed in to several environments and pass no --env, the run is needs_choice with choice: "environment". It uses that environment's current account, unless --business (and --user), or APPBUILDER_BUSINESS (and APPBUILDER_USER), names another saved account; then the environment without --env is the only one where that name matches a saved account, whether or not any account there is current, and several such environments are needs_choice the same way (see Saved accounts). If you're signed out, it fails with credentials_invalid and tells you to run appbuilder login; it never falls back to an API key. API keys (deprecated) are used only with -c <path> or --profile <name>, and never touch the session; --env, --business or --user with either is a usage_error, and APPBUILDER_BUSINESS and APPBUILDER_USER are ignored. The credentials file must name a valid environment (go, fmo, staging, integration, test1); a missing or invalid one is refused with credentials_invalid rather than defaulting to production. Empty apiKey, userId or businessId are refused the same way. A .kizenapp/config.json activeCredentialProfile no longer selects an API key: the push uses the session and prints a one-line notice to stderr, so --json output stays clean. credentialMode: "local" doesn't affect block commands.
Write gating (headless). --dry-run is always a dry run. Without --yes the run is a dry run too, and stderr says Dry run only: pass --yes to write. With --yes, go and fmo also need --allow-production (else production_requires_flag). If the target dashlet was pushed from here before and its content has since been changed in Kizen, both dry runs and writes stop with drift_detected unless --force is passed.
Remembered target. A successful write records the target in .kizenapp/pushes.json (kept gitignored): one entry per environment, business, plugin and block. Pushing the block somewhere else replaces the entry. With no --dashboard, the next push goes to the remembered dashlet. If that dashboard or dashlet is gone, the push fails with remembered_target_missing and never silently creates a new one. When only the dashlet is gone the failure lists the dashboard's target choices, and --create adds a new block there; otherwise pass --dashboard <id> to choose another target, or --forget to clear it (honored in dry runs too; the result then carries "forgotten": true). A missing pushes.json just means nothing is remembered yet; one that can't be read, isn't valid JSON, or isn't a JSON array fails the push with local_error before anything is written, so fix or delete it.
Size. A dashlet created by push starts default_w columns wide, or min_w when there's no default_w, or 6 when neither is set. Its height works the same way with default_h, then min_h, then 3 rows. That size is clamped into whichever of min_*/max_* the block's config.json sets, then into the grid (2 to 12 columns, 1 to 99 rows). The pushed content carries exactly the sizes config.json writes, the same as block export. Updating an existing dashlet never changes its layout or size.
The pushed content includes the plugin's src/views/ components as views, in the same shape as block export. They count toward drift detection: a view changed in Kizen since the last push stops the push with drift_detected. Blocks in plugins without views push exactly as before, and existing pushes.json entries stay valid.
block export and block push fail with invalid_block if a view's api_name, name, script, html, css or event script contains a NUL character or {__ref:. The field is reported as views.<api_name>.<field>. A warning appbuilder/views-unmatched appears if some src/views/ components couldn't be matched to packaged views.
Matching. With --dashboard and neither --dashlet nor --create, the push updates the dashlet recorded in .kizenapp/pushes.json for that dashboard, else the single custom code dashlet named appbuilder:<plugin_api_name>/<block_api_name> (the name given to every dashlet push creates). No match, or several, is needs_choice.
With --json, every result is one pretty-printed JSON object on stdout, and the human summary goes to stderr. Headless without --json prints human text instead: success and dry-run output on stdout, errors on stderr. A dry run:
{
"ok": true,
"applied": false,
"dryRun": true,
"reason": "no_yes",
"action": "update",
"environment": "staging",
"businessId": "b0c6…",
"dashboard": { "id": "5f1e…", "name": "Sales overview", "type": "dashboard" },
"dashletId": "9a2d…",
"url": "https://v2.staging.kizen.com/dashboard/5f1e…",
"resolvedBy": "remembered",
"block": {
"pluginApiName": "my_plugin",
"apiName": "pipeline_summary",
"name": "Pipeline summary"
},
"method": "PATCH",
"path": "/dashboards/5f1e…/dashlet/9a2d…",
"body": { "config": { "…": "…" } },
"warnings": []
}reason is flag (--dry-run) or no_yes (headless without --yes). action is create or update, method is POST or PATCH, and dashletId is null for a create. resolvedBy is flag, remembered, push_map or name. dashboard.type is dashboard, homepage or chart_group.
An applied write:
{
"ok": true,
"applied": true,
"dryRun": false,
"action": "created",
"environment": "staging",
"businessId": "b0c6…",
"dashboard": { "id": "5f1e…", "name": "Sales overview", "type": "dashboard" },
"dashletId": "c41b…",
"url": "https://v2.staging.kizen.com/dashboard/5f1e…",
"refresh": {
"dashboardId": "5f1e…",
"script": "await window.__kizenCustomBlocks?.refresh('5f1e…')"
},
"resolvedBy": "flag",
"block": {
"pluginApiName": "my_plugin",
"apiName": "pipeline_summary",
"name": "Pipeline summary"
},
"remembered": true,
"warnings": []
}action is created or updated. remembered is false (with a warning) when .kizenapp/pushes.json couldn't be written; the push itself still succeeded.
refresh is only on applied writes, and only when the dashboard id is a uuid; otherwise it's left out. script is exactly await window.__kizenCustomBlocks?.refresh('<dashboardId>'). Run it in a Kizen tab that already has url open to refetch that dashboard in place instead of reloading the page. The hook is registered on dashboards, homepages and chart groups. When it's available on the page, the script resolves to { refreshed: true, dashboardId }. Otherwise it resolves to undefined: the page is an older Kizen build without the hook. refreshed: true means the dashboard query was invalidated, not that the target was on screen, which is why the agent matches the tab by url and screenshots afterwards. Human output prints the same script on a Refresh an open tab: line after the URL.
A failure exits 1:
{
"ok": false,
"code": "needs_choice",
"message": "Choose a block to update on \"Sales overview\", or create a new one; pass --dashlet <id> or --create.",
"choice": "target",
"choices": [
{
"value": "9a2d…",
"label": "Pipeline summary",
"args": ["--dashboard", "5f1e…", "--dashlet", "9a2d…"],
"pushKey": "appbuilder:my_plugin/pipeline_summary",
"isFromThisPlugin": true
},
{ "value": "new", "label": "Create a new block", "args": ["--dashboard", "5f1e…", "--create"] }
]
}Optional failure fields: choice (environment, block, dashboard or target) and choices with needs_choice, remembered_target_missing and a --dashlet that is not_found; issues (the packager's validation issues) with validation_failed; field with invalid_block; hint with auth_failed and forbidden. Each choice's args are the exact CLI arguments that select it, to be appended to the command (dashboard choices also carry surface and canEdit).
| Code | Meaning |
|---|---|
needs_choice |
An environment, block, dashboard or target must be chosen; see choice and choices |
validation_failed |
The plugin failed validation |
invalid_block |
The plugin has no blocks, or the block would be rejected (field names the problem) |
credentials_invalid |
Not signed in (run appbuilder login), or the -c/--profile file's environment is missing or invalid |
auth_failed |
Kizen rejected the credentials |
forbidden |
The credentials can't access the dashboard |
not_found |
The dashboard, or the --dashlet on it, doesn't exist |
remembered_target_missing |
The remembered dashboard or dashlet is gone; nothing is created without --create |
drift_detected |
The dashlet was edited in Kizen since the last push; --force overwrites |
production_requires_flag |
--yes against go / fmo without --allow-production |
usage_error |
Conflicting flags (--dashlet with --create, --dashlet without --dashboard), a non-custom-code --dashlet, or a --business (or APPBUILDER_BUSINESS) that matches no saved account, or several |
network_error |
Kizen couldn't be reached |
api_error |
Any other Kizen API failure |
local_error |
A local failure that isn't a validation error: no kizen.json, packaging, reading .kizenapp/pushes.json, or writing it for --forget |
Sign-in sessions add these cases: not being signed in is credentials_invalid; several signed-in environments and no --env is needs_choice (choice: "environment", each choice { value, label, args: ["--env", <env>] }), and so is a --business (or APPBUILDER_BUSINESS) that matches saved accounts in several environments, whose retry commands keep --business and --user; a session that needs a new appbuilder login (expired, rejected or unreadable) is auth_failed; --env, --business or --user with -c or --profile, or an unknown --env, is usage_error; a --business (or APPBUILDER_BUSINESS) that matches no saved account, or several, is usage_error with a message that lists them, and so is --user without --business (or APPBUILDER_USER without APPBUILDER_BUSINESS); an unreachable token endpoint is network_error; and any other failure to refresh the session is api_error.
Lists where a block can be pushed: the business's dashboards and homepages plus its custom objects, or with --object one custom object's chart groups. Always headless.
The CLI honors HTTP_PROXY/HTTPS_PROXY and NO_PROXY on Node versions that support it (24.14+, or with NODE_USE_ENV_PROXY=1 set).
customObjects is sorted by name and ends with Contacts ("fetchUrl": "client") when the CLI can look up the business's contacts object; Kizen doesn't list Contacts with the other custom objects, so if that lookup fails, Contacts isn't included. --object <contacts id> lists Contacts' chart groups like any other object's.
| Flag | Default | Purpose |
|---|---|---|
-c, --credentials <path> |
the sign-in session | API key (deprecated): credentials JSON file to use |
--profile <name> |
the sign-in session | API key (deprecated): stored credential profile to use |
--object <id> |
— | List the chart groups of this custom object (or Contacts) |
--json |
off | Print JSON instead of tables |
--env <env> |
as for block push |
Environment of the session to use. Not with -c/--profile. |
--business <id or name> |
as for block push |
Saved account to use. Not with -c/--profile. |
--user <user id> |
as for block push |
With --business, the user id of that saved account. |
Credentials, --env, --business and --user work as for block push (see appbuilder login).
{
"ok": true,
"environment": "staging",
"businessId": "b0c6…",
"surfaces": {
"dashboard": [
{
"id": "5f1e…",
"name": "Sales overview",
"type": "dashboard",
"dashletsCount": 4,
"employeeAccess": "Owner",
"hidden": false,
"canEdit": true
}
],
"homepage": []
},
"customObjects": [
{ "id": "71d0…", "objectName": "Deals", "fetchUrl": "pipeline" },
{ "id": "c3a9…", "objectName": "Contacts", "fetchUrl": "client" }
]
}With --object <id> the shape is { ok, environment, businessId, object: { id, objectName, fetchUrl } | null, surfaces: { chart_group: [...] } }. canEdit is true for Owner, Admin or Edit access and null when Kizen reports no access level. Failures use the same shape and codes as block push.
The sequence a coding agent follows to build a block and put it on a dashboard. After upgrading the CLI, run appbuilder setup-claude so the agent's skill matches the new commands.
appbuilder block export <api_name>
appbuilder block targets --json
appbuilder block targets --object <object_id> --json # chart groups only
appbuilder block push <api_name> --dashboard <id> --json # needs_choice → choices (existing blocks + new)
appbuilder block push <api_name> --dashboard <id> [--dashlet <id> | --create] --dry-run --json
appbuilder block push <api_name> --dashboard <id> [--dashlet <id> | --create] --yes --json
# change requests: edit, re-validate, then
appbuilder block push <api_name> --yes --json # remembered target, zero questionsAfter each applied push, an agent that can drive the user's browser refreshes the open tab instead of reloading it: find the tab whose origin and path match url, run refresh.script there, and check that the block rendered. If the script returns undefined, the hook isn't available on that page, so navigate the tab to url instead. If no tab has url open, open it in a new one. refresh is absent when the dashboard id isn't a uuid; navigate to url in that case.
Guardrails:
- Never pass
--allow-productionunless the user named production. - Stop on
drift_detectedand ask the user before retrying with--force. - On
needs_choice, ask the user usingchoices(labels to show,argsto append). - On
remembered_target_missing, ask whether to--createa new block rather than creating one.
Browser sign-in, the default for block push and block targets. API keys are deprecated for these commands and are used only when you pass -c or --profile.
appbuilder login [--env <env>] [--business <id or name> [--user <user id>]]
appbuilder login [--env <env>] --list [--json]
appbuilder whoami [--env <env>] [--business <id or name> [--user <user id>]]
appbuilder logout [--env <env>] [--all | --business <id or name> [--user <user id>]]
appbuilder auth-header [--env <env>] [--business <business id> --user <user id>]
appbuilder block push [api_name] [--env <env>] [--business <id or name> [--user <user id>]] [flags]
appbuilder block targets [--env <env>] [--business <id or name> [--user <user id>]] [flags]login opens Kizen's consent page in your browser and prints the URL in case the browser doesn't open. There, you pick the business the session is for. The CLI waits up to 5 minutes for the browser to return to http://127.0.0.1:<port>/callback. It uses OAuth 2.0 authorization code with PKCE (S256). It only trusts the server's OAuth metadata when its issuer is exactly the environment's origin (for example https://staging.kizen.com). It registers itself with the server on first use (dynamic client registration), so you don't need an API key or a client secret. A request to the callback with the wrong state gets a 400 and is otherwise ignored, so the sign-in keeps waiting for the real callback. Each session belongs to one user and one business, which the CLI looks up with /auth/bootstrap right after sign-in. If that lookup fails, nothing is saved. Signing in again to the same account replaces its session. A block push or block targets that's already running keeps to the account it started with: switching to another account meanwhile doesn't affect it, and neither does signing in again to the same account. Signing out of that account stops it with a sign-in error. If the browser shows an error instead of the consent page, the saved app registration is probably stale. The sign-in then times out and clears that app registration, so running appbuilder login again registers afresh.
Every account you sign in to stays saved on this machine, and one of them is current for each environment. whoami, auth-header, admin, block push and block targets use the current one unless you name another.
To use another saved account for one command without switching, pass --business <id or name> (and --user <user id>) to whoami, admin, block push or block targets, or set APPBUILDER_BUSINESS (and APPBUILDER_USER) in its environment. The flags win over the variables, and the variables over the current account; an empty variable counts as unset. The value is matched as for login --business below, among the saved accounts of one environment. With --env, that environment. Without it, the only environment where the value matches a saved account; whether any account there is current doesn't matter. If it matches in several environments, the command fails, names them and asks for --env. If it matches nowhere, the environment is the only one with any saved accounts, current or not, and the command fails listing them; with saved accounts in several environments, pass --env. If it matches no saved account in the environment, or several, the command fails and lists them; it never falls back to the current account. Naming an account never changes which one is current. auth-header ignores the variables, so an MCP headersHelper always serves the current account.
To switch without the browser, which is how scripts and agents should do it:
appbuilder login --env staging --list # one line per saved account; the current one is marked
appbuilder login --env staging --list --json # [{ businessId, userId, businessName, fullName, email, current, readable }]
appbuilder login --env staging --business <business id or name>
appbuilder login --env staging --business <business id> --user <user id> # two users of one business--business picks the saved account whose business id is exactly the value, or else whose business name matches it, ignoring case. --user narrows that to the account with exactly this user id, for when two users of one business are saved; it only works together with --business. Before switching, the CLI checks that the saved session still works by calling /auth/bootstrap with it, refreshing the token first if it's about to expire. On success it prints the same output as a browser sign-in. If the value matches several saved accounts, the command fails and lists them with their business ids, so you can retry with an exact id. When the matches are users of the same business, the error lists their user ids and suggests --business <id> --user <user id>. Commands the CLI prints for you to run leave plain values bare and single-quote the rest, so they're safe to paste into a shell. A network or server error leaves the current account as it was and exits with an error. Neither form of --list ever prints a token.
Two cases fall back to the browser: the saved account's refresh token has expired or been rejected (the CLI deletes that saved account first), or no saved account matches --business (and --user), whatever else is saved. Before the browser opens, login prints a short explanation to stderr: why it needs the browser, that a sign-in window is opening, which business to choose on Kizen's consent page, and the URL to open by hand if no window appears. At a terminal it addresses you; otherwise it addresses the agent or script running the command, which should pass it on to the person who signs in. Because the URL and instructions print before the sign-in finishes, an agent should run appbuilder login as a background command and relay its stderr to the user while it waits. After the sign-in, the CLI checks the account: after an expired saved account it must be exactly that account, and with no saved match it must match --business by business id or name, and --user by user id when given. If it does, it becomes current as usual. If the person signed in to another business, or as another user, the new account is saved but not made current, the command prints which account it got and the commands to use it or to try again, and it exits with code 1. If that account is already the current one, it says so instead.
When both stdin and stdout are terminals and you pass neither --business nor --list, login shows a picker of the saved accounts, labeled by business name, business id and email, with "Sign in with the browser" as the last option. In every other case, login never prompts: without --business it runs the browser sign-in, and if accounts are saved it first prints a hint about --business and --list to stderr.
logout removes the current account's session and leaves the other saved accounts. logout --business <id or name> removes one saved account, matched the same way as for login; if it's the current account, you're signed out too. logout --all removes every saved account for the environment. When no environment has a current account, both --business and --all need --env.
| Command | --env default |
What it does |
|---|---|---|
login |
go |
Signs in with the browser, or switches to a saved account, and stores the session |
whoami |
the only environment you're signed in to; with --business, the only one where it's saved |
Calls /auth/bootstrap with the current account's session, or the saved account --business names, and prints the user and business. Tokens are never printed. |
logout |
the only environment you're signed in to | Deletes the current account's session, one saved account (--business), or all of them (--all). Local only: the refresh tokens stay valid on Kizen. |
auth-header |
from the MCP server URL (see below) | Prints {"Authorization":"Bearer <access token>"} for a Claude Code MCP headersHelper. With --business and --user (used together, by id), it serves only that saved account, whether or not it's current; appbuilder admin passes them. |
The production write gate (--allow-production) and every other flag of block push and block targets work the same with a session as with an API key.
Sessions are stored in ~/.kizenappbuilder/oauth/ (directory 0700, files 0600), separate from API-key profiles. Each saved account has its own file, <env>.<business id>.<user id>.json, and <env>.json names the current account. Access tokens last an hour and refresh automatically: when less than a minute is left, or once after a 401. Refresh tokens rotate on each use. Each process makes one refresh at a time, and every file is written atomically. Concurrent appbuilder processes may each refresh, which is safe because Kizen returns the same token pair when a refresh token is reused within 5 minutes. Before saving, a refresh re-reads the session: it keeps the newer tokens if another process already refreshed or you signed in again to the same account, and it fails with a sign-in error if you signed out of that account meanwhile. A switch to another account doesn't affect it. If the refresh token is rejected, the CLI keeps the tokens another process already refreshed to; otherwise it deletes that account's file, and the command asks you to run appbuilder login again (with --env <env> for an environment other than go). A refresh only ever writes or deletes its own account's file, and only login and logout change which account is current, so a refresh that finishes late can't undo a logout or a switch.
A session file that is corrupt or can't be read isn't treated as "not signed in". Commands that use it report the file's path and ask you to run appbuilder login (with --env <env> for an environment other than go). appbuilder logout --env <env> signs you out either way. If the session file is the problem, logout removes it. If <env>.json is the problem, logout removes that and leaves the saved accounts, and logout --all removes those too. login --list still lists a saved account whose file can't be read, by its ids.
Kizen's MCP server at /api/mcp accepts the same session. As a Claude Code headersHelper, appbuilder auth-header prints {"Authorization":"Bearer <access token>"} as one line on stdout, refreshing the token first if it's about to expire.
To set it up, run these from any directory:
npx @kizenapps/cli setup-claude --mcp --env staging
npx @kizenapps/cli login --env stagingsetup-claude --mcp only registers the server kizen-<env> at user scope, by running claude mcp add-json (--dry-run only prints that command). It writes no files, in a plugin directory or anywhere else. The helper is pinned to the version of the CLI you ran it with. For CLI version 1.16.0, for example, it's equivalent to this POSIX-shell command:
claude mcp add-json --scope user kizen-staging '{"type":"http","url":"https://staging.kizen.com/api/mcp","headersHelper":"npx -y @kizenapps/cli@1.16.0 auth-header"}'- An existing server with that name is left unchanged unless you pass
--force. After upgrading the CLI, runsetup-claude --mcp --forceto pin the helper to the new version. --forceonly replaces the user-scope server. A server with the same name at local or project scope (for example, one you added by hand without--scope user) takes precedence over it in that project. Remove that one withclaude mcp remove kizen-<env>(run in the project) if you want the user-scope server there.- If the
claudecommand isn't installed, it prints this command for you to run instead, and still succeeds. - If you aren't signed in to that environment yet, it reminds you to run
appbuilder login(with--env <env>for an environment other thango). If the saved session can't be read, it prints that error, with the file's path, instead. Either way the server is still registered. - If registering fails, the command exits with code 1.
The helper runs through npx, which reuses its cached copy of the CLI (running setup-claude through npx fills that cache), so it starts in about a second. That works when Claude Code was started from a shell with node on PATH. If it wasn't (for example, with nvm and Claude Code launched outside a terminal), use absolute paths instead, from which node and which appbuilder: "headersHelper":"/absolute/path/to/node /absolute/path/to/appbuilder auth-header". appbuilder starts with #!/usr/bin/env node, so the path to appbuilder alone isn't enough. Use the absolute paths too when testing a local or pnpm link build whose version isn't published, because npx can't fetch that version.
User scope is recommended: Claude Code then passes the helper its environment variables and doesn't wait for project trust before running it.
The helper picks the environment from the MCP server URL that Claude Code passes it in CLAUDE_CODE_MCP_SERVER_URL: the environment whose API origin (scheme, host and port) matches that URL's origin. A URL that matches no environment is an error, so a token is never sent to a server it doesn't belong to. If you also pass --env, it must name that same environment, or the helper fails without printing a token. When the variable isn't set (for example, when you run the command yourself), it uses --env, or the only environment you're signed in to.
Claude Code gives the helper 10 seconds, so auth-header uses shorter timeouts than the other commands: 5s in total for token requests, including a retry after another process's refresh. On failure, it prints one line to stderr, such as a reminder to run appbuilder login (with --env <env> for an environment other than go), and nothing to stdout.
auth-header prints a live access token by design: treat its output like a password. The token acts as you, in the one business you chose when signing in, until it expires (up to an hour).
Runs Kizen's admin CLI, kizen, signed in with your appbuilder login session. You don't install kizen yourself: you only need Python 3.12 or newer on PATH.
appbuilder admin [--env <env>] [--business <id or name> [--user <user id>]] [--] <kizen args...>
appbuilder admin init [--force]--env picks the session, and defaults to the only environment you're signed in to. kizen runs as that environment's current account, unless --business (and --user), or APPBUILDER_BUSINESS (and APPBUILDER_USER), names another saved account; then --env defaults to the only environment where that name matches a saved account (see Saved accounts). The wrapper's own options (--env, --business, --user, -h/--help) come first and end at the first other argument or at --. Everything after that goes to kizen verbatim, so appbuilder admin status --help shows kizen's help, and appbuilder admin --help shows this wrapper's. init is the wrapper's own subcommand only as the first argument; appbuilder admin -- init passes init to kizen.
On first use, appbuilder admin installs kizen into a private virtual environment under ~/.kizenappbuilder/kizen/<version>-<sha>/ and reuses it afterwards without touching the network. appbuilder admin init does that ahead of time, and needs no session. It uses the newest of python3.14, python3.13, python3.12 and python3 that is 3.12 or newer, downloads the kizen release this CLI version pins (currently 0.3.0) from its GitHub release, checks its sha256, and installs it and its dependencies from PyPI with pip. Progress goes to stderr; stdout is kizen's. Each time it makes a new install, it removes installs for other kizen versions that have gone unused for 30 days.
If the install is damaged (for example, the Python it was built with was removed), the command says so and changes nothing. appbuilder admin init --force deletes the install and installs it again. Don't run it while appbuilder admin is running, since a running kizen uses that install.
Under app-builder, kizen gets your session through KIZEN_AUTH_HELPER: it names appbuilder auth-header --env <env> --business <business id> --user <user id>, which kizen runs when it needs a token, and KIZEN_BASE_URL, KIZEN_BUSINESS_ID and KIZEN_USER_ID identify the session. With --business and --user, auth-header serves only that account, whether or not it's current, so switching accounts with appbuilder login while kizen runs doesn't affect it. kizen keeps the token for the life of its process and runs the helper again only after a 401. If you sign out of that account while kizen runs, its next token fetch (at startup, or after a 401) fails. The token never appears in kizen's environment or arguments. KIZEN_PROFILE and CLAUDE_CODE_MCP_SERVER_URL are removed from its environment. Under app-builder, kizen refuses init, envs list and upgrade (upgrade --check reports that app-builder manages the install). The command exits with kizen's exit code.
To try a local kizen build, set APPBUILDER_KIZEN_WHEEL to the path of a kizen_builder-<version>-….whl. It's installed instead of the pinned release, with no download or hash check.
Four environment variables point the CLI at a different Plugin Wizard (encryption API) host. Precedence for the dev target:
PLUGIN_WIZARD_URL— forces a single host for all targets, dev and prod alike.PLUGIN_WIZARD_URL_DEV— explicit dev host.APPBUILDER_LOCAL_DEV— any non-empty value routes the dev target tohttp://localhost:9823.- Default:
https://plugin-wizard.kizen.dev.
Prod follows the same order minus step 3: PLUGIN_WIZARD_URL, then PLUGIN_WIZARD_URL_PROD, then the default https://plugin-wizard.kizen.com. An empty-string value is treated as unset at every level.
CLAUDE_CODE_MCP_SERVER_URL is set by Claude Code when it runs appbuilder auth-header, which uses it to pick the environment (see Using the session from Claude Code).
APPBUILDER_BUSINESS and APPBUILDER_USER name a saved account for whoami, admin, block push and block targets to use instead of the current one, like --business and --user, which win over them. APPBUILDER_USER only applies with APPBUILDER_BUSINESS, and an empty value counts as unset. auth-header ignores them, and so do block push and block targets with -c or --profile (see Saved accounts).
APPBUILDER_KIZEN_WHEEL is the path of a local kizen_builder-<version>-….whl for appbuilder admin to install instead of the kizen release this CLI version pins, with no download or hash check (see appbuilder admin).
build and dev create .kizenapp/ next to your kizen.json and add it to .gitignore automatically. It holds machine-local state only — keep it gitignored:
| Path | Contents |
|---|---|
bundle.json |
The packaged, minified, validated plugin bundle the viewer loads. |
config.json |
Per-project preferences: credential mode, active profile name, last viewed path, encryption target. |
pushes.json |
The remembered block push targets: one dashlet per environment, business, plugin and block. |
.chrome/ |
The dedicated Chromium user-data directory for the viewer — cookies and session state included. |
venv/ |
The Python virtualenv used to execute code steps locally. Rebuilt when its interpreter is too old for the bundled requirements. |
Plugin scripts can attach a JSON context payload to an in-app navigation:
this.openWindow('/some/path', '_self', { recordId: 'abc', mode: 'edit' });The engine transmits that payload out of band through sessionStorage and appends a session_data_key to the URL; the destination page reads it back with readNavigationContext / consumeNavigationContext (or the useAppNavigationContext React hook). The sandbox surfaces this end to end:
- Navigation Context panel — a slide-out panel on the Routable Pages browser (toggled by the
contextbutton in its chrome, which shows a live event count) with a reverse-chronological log of every navigation that carried a context payload, plus any externalwindow.open(which the engine drops context from). Each entry shows the target, whether a context payload rode along, its key and byte size, an expandable pretty-printed payload, and a status badge. - Simulated destination page — navigating to an in-app path that isn't a routable page in your plugin renders a stand-in for the real Kizen page. When the URL carries a valid context key it shows the payload and lets you Consume, Clear, or Re-read it, so you can confirm the destination sees exactly what the script sent (and that a re-read after consuming sees nothing).
How the two navigation targets behave:
_self(same-tab, relative) — the context stays in this tab'ssessionStorageacross the navigation, so the destination reads it normally. The sandbox reads (does not consume) it when logging._blank(new-tab, relative, same origin) — the engine stores the context, opens the tab, then immediately deletes its own copy, relying on a real browser having already copiedsessionStorageinto the new tab.- External / cross-origin URLs — context is never attached and is dropped by design; these appear in the log as
ignored (external).
sessionStorage in the sandbox: the "opener" and the simulated destination share one sessionStorage. To keep the engine's reader helpers working, the harness snapshots the payload and re-inserts it under the same key immediately after the engine deletes it. Reading and consuming the payload behave as they do in a browser.
Your plugin's own pages: navigating to a path that matches one of your plugin's routable pages activates that page's tab without carrying the URL through, so the page doesn't receive its session_data_key. To inspect what a destination receives, use the simulated destination page (any non-routable in-app path).
Error surfacing: if sessionStorage writes fail (e.g. quota exceeded, storage disabled), the engine navigates without context and reports a message through the same onError path every script artifact already uses — it shows in that artifact's result UI and the DevTools console, not as a separate log entry, because on failure the URL carries no key for the harness to detect. Context is serialized with JSON.stringify inside the worker script: circular references and BigInt values throw there before any navigation happens, while functions, undefined values, and symbols are omitted.
See CONTRIBUTING.md for repository layout and build scripts.
GPL-3.0-only. See LICENSE.md.