Gotchas — the consolidated trap list#
What this covers: every known trap, quirk, and silent-failure mode in the Kizen plugin platform, in one place, grouped by topic. Each entry states the trap in one line, explains why it happens and what to do instead, and links to the doc that owns the full contract. This file is the safety net — scan the relevant section before and after building any surface.
See also: 18-recipes.md for end-to-end worked examples,
04-worker-runtime-api.md for this.* contracts,
15-errors-and-observability.md for the error-handling doctrine.
Manifest & packaging#
The manifest
enginefield must be exactly"1.0.0"— it is a fixed value, not a version choice. The packager validates against an exact-match allowlist of['1.0.0'], and nothing at runtime branches on it. The engine library itself is at 1.9.1; do not try to express engine requirements through this field. → 03-manifest-reference.mdkizen.jsoncan be a top-level JSON array (multi-plugin repo) — parse and tool accordingly. Each entry is a full manifest and needs a uniqueapi_name(errormanifest/duplicate-api-name) and its ownentrydirectory. Scripts that assume a single object break on these repos. → 03-manifest-reference.mdArtifacts are discovered by directory convention under
entry, never declared inkizen.json. The manifest holds only plugin-level fields (services, setup assistants, base_config). Adding an array of blocks/actions to the manifest does nothing; createsrc/<surface>/<name>/directories with the reserved filenames instead. → 03-manifest-reference.mdReserved filenames are the contract — a misspelled script filename is silently ignored.
script.js,script.py,config.json,styles.css,message.js,callback.js,calendars.js,events.js,index.html,eventScripts/<name>.js. The manifest never names a script file; only these names are read. → 03-manifest-reference.mdDirectory-derived api_names collapse camelCase — always set
api_nameexplicitly inconfig.json. The fallback sanitizer lowercases the directory name, collapses hyphen and whitespace runs to_, then drops characters outside[a-z0-9_]:archiveChildRecordsbecomes api_namearchivechildrecords, andarchive-child-recordsbecomesarchive_child_records. Underscores are preserved, so a snake_case directory likezero_heightsurvives unchanged — it is camelCase directories that produce a name you did not expect. Anything that references the artifact by api_name (showViewInModal,runBlockScript, associations) then fails to resolve. → 03-manifest-reference.mdViews compile into
routable_pages— page and view names must be unique across BOTHpages/andviews/. A duplicate fails packaging (structure/duplicate-component-name). Views also cannot carry awhencondition (they are not in the flag-gated artifact list) — gate the surfaces that open the view instead. → 03-manifest-reference.mddataAdornments/config.json takes noapi_name— it is the only config-required directory without one. Its identity is the directory +field_type. Don't add one expecting it to matter. → 12-routes-calendars-adornments-settings.mdThe source directory is
objectSettingsItems/but the packaged artifact key isobject_settings_menu_items. Remember the naming split when reading publish payloads, bundle output, or API responses. → 03-manifest-reference.mdthumbnail.pngis build-optional but publish-required, and its location is strict. It must sit at the first path segment underentry(src/thumbnail.png); a nestedsrc/images/thumbnail.pngis silently ignored and publish then fails with "Thumbnail is required for publishing". PNG only, exactly one. → 16-release-and-publish.mdrelease_branch(singular) is not a field — a real-world typo trap. Nothing reads it; the plugin silently gets the defaultrelease_branches(the repo's default branch). The field isrelease_branches(plural, array). → 03-manifest-reference.mdA flat string
developer_business_idis sent verbatim to EVERY release environment. A business id exists in only one environment, so a flat id + multiple release environments publishes into environments where the business doesn't exist (packager warningmanifest/developer-business-id-environments). Use the per-environment object form keyed by concrete environment names — alias keys (dev,prod) are rejected there. → 03-manifest-reference.mdNever reason about raw
release_environmentsentries — aliases expand.prod→ go + fmo;dev→ staging + integration;testing→ e2e-integration + e2e-staging. Counting entries or matching strings without expanding through the alias map gives wrong answers. → 03-manifest-reference.mdconfig_templateis effectively vestigial once a setup assistant exists. Prefer sourcing install config from the setup assistant; shipconfig_template: {}unless you have a specific reason (static config with no assistant). → 03-manifest-reference.mdA
whencondition flipsblock_loading_for_setup: true— but only from 5 of the 7 surfaces that acceptwhen. Blocks, data adornments, toolbar items, Agentic Workflow steps and calendar sources set the flag. Floating frames and object settings items acceptwhenand do NOT set it — so a plugin whose only conditions live on those two surfaces ships withblock_loading_for_setup: false, and their conditions are evaluated against config that may not have loaded yet. When the flag is set the behavior is expected, not a bug: the host holds artifact loading until install config exists. Don't set the flag manually. → 03-manifest-reference.mdA fixed-position floating frame requires
minimized_style: "circle". The fixed anchor IS the circle trigger element; withbarornoneevery fixed-positioning path silently no-ops and the frame's position freezes. The packager build-errors on this combination (structure/fixed-frame-minimized-style). → 11-output-ui-iframes-frames.mdPackager TypeScript types are advisory; the engine runtime types are the authority. The packager's setup-assistant field type is missing
qr/image/linkand ~20 real props; itsconflict_resolutionenum omitsupdate_if_blank. Typechecking authored JSON against@kizenapps/packagerproduces false errors on valid manifests. → 03-manifest-reference.mdAn Agentic Workflow step's
"script"key inconfig.jsonis ignored — the packager always readsscript.pyfrom disk. Embedding source in config does nothing. → 07-automation-steps.mdThe local tool is
@kizenapps/cli, run asnpx --yes @kizenapps/cli <command>— there is nokizenCLI for plugin development. Commands:create,build,dev,encrypt,report,icons. Pass--yesso npx's install prompt can't hang the command in CI or a non-interactive shell. It writes gitignored local state to.kizenapp/. → 02-getting-started.mdcreateis an interactive terminal UI with no flags — it cannot run unattended. Without a real TTY it exits non-zero withRaw mode is not supported on the current process.stdinand scaffolds nothing, so agents and CI jobs should build the repo layout directly instead of shelling out tocreate. The other commands need no TTY. → 02-getting-started.md.kizenapp/must stay gitignored — it can contain an embedded browser profile with real credentials. A straygit add .on a repo missing the ignore rule leaks cookies/login data. The CLI adds the ignore entry itself; verify it before committing. → 02-getting-started.mdToolbar, adornment, and minimized-circle icons come from the platform icon set only.
npx --yes @kizenapps/cli iconsis the authoritative list; arbitrary icon names silently fail to render (adornments/frames can usecustomIconFile→ inlineddata:image/URI instead). → 12-routes-calendars-adornments-settings.mdA
whenon an action, page, route script or view is silently discarded at package time. Only blocks, data adornments, floating frames, object settings items, toolbar items, calendar sources and Agentic Workflow steps support conditions. The packager reads a fixed key set from the other four and dropswhenwith no error and no warning, so the artifact ships unconditionally enabled and looks exactly like an expression that evaluated true. → 03-manifest-reference.mdTwo data adornments on the same
field_typecannot be gated independently. Because an adornment's identity is itsfield_typerather than anapi_name, the host keys evaluated conditions byfield_typetoo — so same-type adornments in one plugin share a single result and the last one evaluated wins for all of them. Give them one sharedwhen, or fold them into a single adornment. → 12-routes-calendars-adornments-settings.md
Workers & HTTP#
Every script execution is a brand-new worker — nothing on
this, module scope, or closures survives. Not between a main script and its event scripts, not between two event-script runs, not acrossrunEventScript. What persists: the painted DOM from the lastoutputUI,sessionData, user config, and the backend. → 04-worker-runtime-api.mdEvent scripts are isolated units — there are no shared helper modules, and there is no
import/require. Script bodies are compiled withAsyncFunction; duplicating small helpers (anesc()ordescribeError()) across scripts is the correct, unavoidable pattern, not a smell. → 04-worker-runtime-api.mdRelative GETs are cached forever within a worker — there is no TTL.
this.get/getWithErrorsmemoize per-URL for the worker's lifetime; withthis.preserve = truethe cache lives across event turns. Pass{ ignoreCache: true }whenever a fresh read matters (config re-reads, readiness checks, read-modify-write cycles). → 04-worker-runtime-api.mdBare HTTP helpers resolve
undefinedon failure after routing the error away — use the*WithErrorstuple helpers exclusively.this.get/post/patch/deletecallthis.onErrorinternally and resolveundefined; the caller can't branch on failure.getWithErrorsetc. return[data, error]and never throw. DELETE resolves[null, null]on 204. → 15-errors-and-observability.mdThere is no
this.put. The transport supports PUT but no public wrapper exists. Use PATCH endpoints, or restructure the call. → 04-worker-runtime-api.mdthis.patchdoes not add theX-Request-Type: kizen-ui-scripting-apiheader the other verbs add. A quirk to know about when anything server-side keys on that header. → 04-worker-runtime-api.mdthis.argsis parsed from a JSON string — invalid JSON silently becomes{}. If your args mysteriously vanish, check that every caller passes JSON-serializable values. → 04-worker-runtime-api.mdThere is no engine-imposed execution timeout — a hung script keeps its worker alive indefinitely. Only a rerun of the same worker identity terminates it. Never await something that can hang without a bound. → 04-worker-runtime-api.md
Worker identity hashes the script body AND args — changing either creates a different worker. Re-running the same identity force-terminates the previous instance; this is how "restart on config change" works, and why two invocations with different args can run concurrently. → 04-worker-runtime-api.md
this.preserve = truekeeps the worker (and its GET cache) alive after completion — a debugging tool, not a production pattern. It leaks memory and makes stale-cache bugs likely. → 04-worker-runtime-api.mdScripts run inside an async IIFE — top-level
awaitand top-levelreturnare valid, butnode --checkrejects them. Syntax-check a script body withnew Function('return (async function(){\n' + body + '\n});')instead of node's file checker. → 04-worker-runtime-api.mdthis.currentUsernests everything under.profile—this.currentUser.first_nameis alwaysundefined. Readthis.currentUser.profile.first_name; string fields default to'', not null. → 04-worker-runtime-api.mdthis.locationthrows on any property outside its snapshot set. It is a Proxy over host/hash/href/origin/pathname/search/port/protocol; reading anything else throws (JSON.stringify works via a toJSON special case). → 04-worker-runtime-api.mdthis.currentBusiness.timezone.nameworks but is outside the typed contract — the declared type has only{ id }. Extra fields (employee_id,client_object.id,timezone.name) are real today; be aware they could tighten. → 04-worker-runtime-api.mdWhen the proxy succeeds but the upstream fails, the error carries
proxyStatus: 200plus the realupstreamStatus. Branch onerror.upstreamStatusfor external-service failures, notproxyStatus. → 06-auth-secrets-services.mdpostFormDatarejects with no reason on failure. Wrap it in try/catch and produce your own error message. → 04-worker-runtime-api.mdinstallThirdPartyScriptnever rejects — a failed install reads as a success. It routes the failure to a fixedonErrorand resolvesundefined, so try/catch catches nothing. Check the returned handle before calling.call(...)on it. → 04-worker-runtime-api.mdinstallThirdPartyScriptis allowlisted to exactly two vendors' widget URLs (Freshworks and Intercom). Any other URL reports an error and resolvesundefined. It is not a general script loader. → 04-worker-runtime-api.mdsetUserConfigis read-modify-write with no locking — concurrent writers can clobber each other. Keep per-user config writes coarse and infrequent; don't fan out parallel writes. → 04-worker-runtime-api.mdWorkers have a real global
fetch()for absolute URLs — but no auth injection and normal CORS apply. Public APIs work without declaring a service; anything needing credentials must go through the service proxy. → 04-worker-runtime-api.mdA failed
/external-integrations/bootstrapmakes EVERY plugin silently absent — and the host blames the install. The error is retried, then swallowed: not thrown, not toasted, not reported to monitoring. The plugin map defaults to{}, so every surface list empties at once. Because the host still reports "fetched", consumers commit to the empty result and explain it wrongly — dashlets say "This block is no longer available", plugin pages say "Plugin Not Found — Error code: 404". Both describe an uninstalled plugin; neither is true. Check the network tab before investigating an install. Your scripts never run, so this is undetectable from inside a plugin. → 01-overview.mdA failed per-user config load degrades
this.userConfigto{}— indistinguishable from "not configured yet". The host catches the error and substitutes{config: {}}; no error flag is injected, and none exists to read. Don't treat empty user config as proof the user never configured the plugin if acting on that is expensive (re-prompting for settings they already saved). UsegetUserConfig()for an explicit re-read that can fail visibly. Note this fetch is not retried — the catch lives inside the query function, so the retry policy never engages. → 04-worker-runtime-api.md
Error handling & observability#
Thrown errors and
this.onErrorroute to platform monitoring and are triaged as PLATFORM issues. Reserve them for genuine platform problems. Expected/user-level failures get ashowToast({ variant: "failure", autohide: false })and areturn— a throw raises a platform alert. → 15-errors-and-observability.mdthis.onErrortransmits onlyerror.message— all structured error data is lost at the worker boundary. Serialize anything you need into the message string. → 15-errors-and-observability.mdthis.onErrordoes not stop the script — onlythrowaborts remaining statements. A script that reports an error then keeps mutating data is a common bug.returnafter reporting. → 15-errors-and-observability.mdError.messageis non-enumerable —JSON.stringify(err)yields"{}". Normalize before display:typeof e === "string" ? e : e?.message ?? JSON.stringify(e). This also bites any script matching error text for control flow. → 15-errors-and-observability.mdA thrown error still "completes" the script from the host's perspective —
runScriptresolvesundefined, and blocking route scripts release. Failure is not a hang; don't build hang-guards around throws. → 15-errors-and-observability.md
Platform API#
Business plugin config PATCH is a WHOLESALE REPLACE — no server-side merge, no validation.
PATCH /external-integrations/business-plugin-apps/{identifier}replaces the wholeconfigblob. Always read fresh (ignoreCache: true), spread the existing config, overwrite only your keys — otherwise you drop__kizen_setup_assistant_values/__kizen_setup_assistant_hashand every sibling key. → 05-platform-api.mdBusiness plugin config cannot be created from a script — the row exists only after install. Before install (or in a sandbox without one), both GET and PATCH 404, and a business-level
completeSetuprejects for the same reason — there is no stored record to merge into. → 05-platform-api.mdNever hardcode your plugin's api_name in scripts — preview builds publish under a suffixed api_name. Hardcoded literals 404 in preview deployments. Always build URLs from
this.pluginApiName(workers) orstate.pluginApiName(setup-assistant scripts). → 05-platform-api.mdPOST /custom-objectswith aname(api-name) key is staff-only — non-staff get 403 with codeforbidden_field_write. Omitnameand capture the server-derivedcreated.name(slugifiedobject_name) from the response, then persist it for later lookups. Example code that sendsnameonly works on staff accounts. → 05-platform-api.mdLogged activities have NO list route —
GET /api/activities/loggedis a 404. OnlyGET /api/activities/logged/{id}exists. To enumerate, usePOST /api/activities/{identifier}/responses(paginated). A "did I already log this?" guard must be a stored flag re-read fresh before the POST. → 05-platform-api.mdGET /api/activitieshas no api_name filter. Bumppage_size(honored to 1000) and match client-side. → 05-platform-api.mdlookupmatches one fixed column, exactly and case-sensitively — and only a confirmed 404 justifies a create.GET /records/{obj}/lookup?identifier=matches email for the contacts object, name for others. Creating a record after an ambiguous error (403/500/timeout) is the duplicate-data bug class. Noteupsertmatches case-insensitively (__iexact) — different semantics. → 05-platform-api.mdContact (client-object) CREATION by field name can 403 — create with field IDs +
unarchive, update by name. Proven path:GET /client/custom-object→ useundeletable_fields.<api_name>.id→ POST with field ids andunarchive: "prompt".PATCH /records/client_client/{id}by name works fine. → 05-platform-api.mdpage_sizefor record search is a query parameter, not a body key.POST /records/{obj}/search?page_size=100withfield_namesin the body. The response narrows fields to exactly the requested api names — include"stage"explicitly if you need it. → 05-platform-api.mdField values read back with inconsistent envelopes — unwrap defensively. A value may be a raw scalar,
{value}, or{id, name}one level nested;record.fieldsmay be an array or an object keyed by field id; dropdown values arrive as option id, option name, or object. Normalize before comparing. → 05-platform-api.mdWrite value formats are strict: dropdown = option id, relationship = scalar related-record id, date =
"YYYY-MM-DD". Files are arrays of uploaded-file UUIDs; phone numbers accept bare E.164. Wrong shapes fail with misleading validation errors. → 05-platform-api.mdDropdown field options: create the field WITHOUT options, then POST options one at a time — never PATCH options. An empty options array is rejected on create, and PATCHing options is a destructive replace. Duplicate option names are rejected case-insensitively. → 05-platform-api.md
Field creation traps:
categoryis required,moneyrequiresmoney_options, api_namestatusis reserved,decimal_options.max_valueabove 999999.99 → HTTP 500. List categories viaGET /custom-objects/{id}/categories(a fresh client object has exactly one). Prefer prefixed api_names for generic nouns. → 05-platform-api.mdsettings-searchresults are permission-filtered — an empty result never proves absence. Fall through toGET /custom-objects/{apiName}before concluding an object doesn't exist. → 05-platform-api.mdThe employee list is silently scoped to the caller when the
all_team_memberspermission is NONE.GET /api/teamreturns only the caller's own row — no 403, no signal. Retrieve-by-UUID andPOST /team/searchare not scoped. → 05-platform-api.mdNo trailing slashes — the API router does not redirect them.
/api/records/my_object/add, never/api/records/my_object/add/. → 05-platform-api.mdBusiness settings:
countryis ISO alpha-2, and currency is NOT a business setting.PATCH /business/mine?v2=truetakes"US", not"United States"; there is nodate_formatfield; currencies come fromGET /api/constants/currencies. → 05-platform-api.mdIntegration-secret updates: PATCH is disabled, and re-sending the record's own
api_nameon PUT causes a 500. Update with full PUT and omitapi_name.valueis write-only and never returned. → 06-auth-secrets-services.mdPlugins have NO inbound HTTP surface — no registerable endpoint for external POSTs. Only the OAuth callback is unauthenticated, and it can't carry data into a plugin. External systems push data through the authenticated ingestion endpoints: the Agentic Workflow webhook trigger, the Webhook SmartConnector (250 KB cap, async), or records upsert (contacts match on email only). All three require Kizen credentials. → 05-platform-api.md
Delete-all loops must refetch page 1 after each batch. Deletions shift the paging window; iterating pages forward skips records. → 05-platform-api.md
Setup assistants & install config#
whenexpression scoping differs by location: artifact configs use{{config.key}}/{{userConfig.key}}; setup-assistant fields use bare{{key}}. Mixing them up makes conditions silently evaluate againstundefinedand artifacts vanish. → 13-setup-assistants.mdConfig value shapes from
this.config/this.userConfigare per-field-type — several are objects, not scalars.select= the whole{label, value}option (read.value);custom_object={objectId, objectName}(read.objectId— interpolating the object yields/records/[object Object]/..., which surfaces as a fake 403);fieldpicker ={fieldId, fieldName, objectId, objectName}(note: field id, not api_name);numberis absent entirely when left blank. → 13-setup-assistants.mdthis.configis a construction-time snapshot — stale within a run. A value you just PATCHed won't appear until the next worker launch. Prefill UI from a fresh GET of the business plugin config, not fromthis.config. → 13-setup-assistants.mdSetup-assistant
services[].api_nameis validated nowhere — a typo silently disables the whole OAuth prerequisite step. Double-check the string againstservices[].service_namein the manifest. Also leaverequired: falsewhile OAuth credentials are placeholders, or setup hard-blocks. → 13-setup-assistants.mdAsync-select companion scripts run in the BROWSER marketplace page, not a worker. No
this.getServiceUrl— hand-build/external-integrations/proxy/${state.pluginApiName}/{service}/.... Guard dependent fetches with a fallback so the URL is valid before the parent field is picked. → 13-setup-assistants.mdThe install modal re-prompt is skipped only when
__kizen_setup_assistant_hashmatches — any non-declarative config write must maintain it. If your code writes config outside the assistant and doesn't preserve the hash sibling, users get re-prompted on every enable (or, conversely, your changes get clobbered on the assistant's next save — keep keys disjoint). → 13-setup-assistants.mdAssistant
actionsentries must reference real action api_names or packaging fails.structure/setup-assistant-action-refthrows at transform time; stale lists copied from other repos are a known failure. → 13-setup-assistants.mdRenaming setup-assistant field keys breaks every
whenclause and script read that referenced them. Keys are case-sensitive and resolve to nothing when missing (artifact silently hides). Migrating config requires preserving exact keys. → 13-setup-assistants.mdSetup-assistant
defaultvalues drivewhen-clause visibility pre-configuration, but do NOT reachthis.configat script runtime. A script reading a key the user never saved getsundefinedeven though the artifact was visible. Default defensively in the script. → 13-setup-assistants.mdUnknown assistant field types silently render an invalid block. There is essentially zero packager validation of assistant fields; malformed assistants publish and fail at runtime. The valid types are:
custom_object, description, container, field, text, number, select, boolean, qr, image, link. → 13-setup-assistants.mdcompleteSetup(payload)REPLACES__kizen_clean_configwholesale — every key missing from the payload is gone. The host assigns it directly:{ ...existingConfig, __kizen_clean_config: payload }. A view that edits one setting must still send every key the plugin depends on, including keys other surfaces of the same plugin read. Build the full object —await this.completeSetup({ ...this.config, apiKey: next })— rememberingthis.configis a load-time snapshot. → 13-setup-assistants.mdEvery
completeSetupcall stamps__kizen_setup_assistant_hash, and nothing checks which surface called it. The hash covers the assistant definition, so stamping it suppresses the install-time setup prompt on the next enable — including after a version bump that changed the assistant. A plugin that callscompleteSetupfrom a block or toolbar item without actually running setup stops prompting for setup. → 13-setup-assistants.mdoptions.levelis only ignored while a setup surface is live — off-surface the caller's value wins. The host resolves the level from the live setup surface, which is why a setup view can't misroute its own write. That protection doesn't extend to a block or toolbar item: there the passed level is honored (defaulting to'business'when absent), so a stray call can write the wrong scope on top of suppressing the setup prompt. → 13-setup-assistants.mdAn unguarded form-submit handler can
completeSetupa blank payload — wiping the config AND suppressing the prompt. Click-path dispatch supplies nothis.args.formData, so a click landing on a<form data-script=…>element's own padding or grid gaps runs the handler withformDataundefined. A setup view that reads the form data and passes it tocompleteSetupthen writes an empty clean config and stamps the hash: the plugin's config is wiped and it stops prompting for setup. Every submit handler needsconst formData = this.args?.formData; if (!formData) return;before it builds a payload. → 13-setup-assistants.mdcompleteSetupdoes not write__kizen_setup_assistant_values— the raw answer store the declarative renderer repopulates its form from. On a plugin that has both a declarative assistant and acompleteSetupcaller, the next declarative save regenerates the clean config from that untouched values store and discards whatcompleteSetupwrote. This isn't a supported combination: whichever style owns a level owns that level's config, so keep to one writer per level. → 13-setup-assistants.mdA multi-step setup view must call
completeSetupexactly once, at its terminal step. A successful call fires the host's completion callback, which closes the setup modal — a mid-wizard call slams the modal shut on an unfinished user. → 13-setup-assistants.mdbase_config.disabled_keyshas no effect on what a view-based assistant saves. It only filters the values a declarative field list writes. The packager emits a warning when the two are combined. → 13-setup-assistants.mdViews aren't feature-flag filtered and take no
whenclause — so a setup view can't be conditionally hidden. Most other artifact types declarewhen; views and pages do not. If setup needs to branch on config or flags, branch inside the view. → 13-setup-assistants.md
UI output & sanitization#
DOMPurify strips
name/idattributes whose VALUE collides with adocumentor<form>property — silently.<input name="name">(alsoaction,title,submit,method,target,elements,style, …) loses its attribute, never reaches FormData, andformData.nameisundefinedwith no error anywhere. Never use a bare DOM-property word as a field name; prefix or hyphenate (your-name— no DOM property contains a hyphen). → 11-output-ui-iframes-frames.mdA second, separate DOMPurify rule drops the whole
valueATTRIBUTE when the decoded value contains a complete tag. Even correctly-escaped<script>…</script>in a hidden input'svalueblanks the attribute on repaint. Ordinary values (O'Brien,a < b,Jane <jane@example.com>) round-trip fine. Don't round-trip tag-tolerant free text throughvalueattributes — carry it insessionData. → 11-output-ui-iframes-frames.md<script>tags, inline event handlers, andjavascript:URLs are stripped fromoutputUImarkup. There is no DOM API in the worker either. All interactivity flows throughdata-script="<name>"→eventScripts/<name>.js, or an embedded iframe messaging back. → 11-output-ui-iframes-frames.mddata-scriptclick dispatch reads the EXACT hit-tested element — no ancestor traversal. A<span>or<svg>child inside adata-scriptbutton swallows the click into nothing. Convention:data-scriptbuttons contain text only. → 11-output-ui-iframes-frames.mdNever apply
[data-script] > * { pointer-events: none; }as a blanket rule — it bricks everydata-scriptform. Inputs and submit buttons inside such forms become unclickable, silent no-ops. → 11-output-ui-iframes-frames.mdClicking a
data-scriptform's padding fires the handler with NOformData. Keepdata-scriptforms tight around their controls and guard handlers for a missingformData. → 11-output-ui-iframes-frames.mdPer-element payloads must ride in per-item
<form>s with hidden inputs — the click path carries only static args. Complex payloads:encodeURIComponent(JSON.stringify(obj))— notbtoa, which throws on non-Latin-1. → 11-output-ui-iframes-frames.mdForm submission never captures the submitter button's name/value. You can't encode "which button was clicked" on submit buttons; use separate forms or hidden inputs per action. → 10-views-modals-forms.md
outputIframeURLs need an explicithttps://scheme — bare hostnames yield a blank frame. With the proxy on,new URL()throws and the frame gets an empty src; the proxy also rejects non-HTTPS targets with an error overlay. → 11-output-ui-iframes-frames.mdVendor cookies inside a proxied frame are third-party cookies — the session that works standalone can fail framed.
SameSite=None; Secureis the baseline requirement everywhere; addingPartitioned(CHIPS) is the forward-compatible best practice. Safari blocks third-party cookies with no escape hatch, so test there and design a cookie-less fallback (token in the frame URL or via postMessage). Also: passing asandboxlist withoutallow-same-origingives the frame subtree an opaque origin and kills cookies outright. Nothing in the Kizen pipeline touchesSet-Cookie. → 11-output-ui-iframes-frames.mdIn proxy mode your
allowlist scopes the INNER frame — and origin-scoped grants must exactly match the framed origin. The parent iframe always carries the fixed ceiling; your list travels via&allow=. A baremicrophoneexpands to'src', which only covers the proxy origin — writemicrophone *ormicrophone https://exact.origin. Empty allow = no powerful features regardless of ceiling. → 11-output-ui-iframes-frames.mdIf the framed page navigates to a different origin, the proxy bridge breaks permanently (
ORIGIN_MISMATCH). Messages stop flowing for the life of the frame; there is no recovery except re-rendering the iframe. → 11-output-ui-iframes-frames.md__dangerouslySkipProxygives up the message bridge, plugin attribution, and origin pinning. Proxied embedding is the documented pattern; skip it only for content that genuinely cannot ride the proxy, and know that no postMessage envelope/unwrapping applies. → 11-output-ui-iframes-frames.mdApp-page
iframe_urlembedding passes NO permissions at all — device access requiresoutputIframe. Page-type iframes are proxied for basic embedding only; microphone/camera/etc. must come from a script surface callingthis.outputIframe(url, allow). → 11-output-ui-iframes-frames.mdthis.outputView(viewId)is NOT supported end-to-end — the host ignores the message. The engine sends the relay but no host renders it; it silently no-ops. The working way to render a packaged view isthis.showViewInModal(viewId). To swap inline content, repaint withoutputUI. → 11-output-ui-iframes-frames.mdFloating-frame
hideHeader()/showHeader()work ONLY on fixed-position frames. Non-fixed frames are dragged by their header, so the calls silently no-op there. → 11-output-ui-iframes-frames.mdFloating frames auto-
show()after their script starts — never callshow()for the initial paint. A frame is hidden until its script runs; the engine reveals it itself. Explicitshow()is for re-revealing afterhide(). → 11-output-ui-iframes-frames.mdA floating frame's
message_handlerreceives ALL window messages, but onlyevent === 'message'proxy envelopes. Proxyloaded/errorenvelopes are routed to dedicated hooks and never reachmessage.js; non-proxy messages pass through raw. Always shape-checkthis.args.eventData. → 11-output-ui-iframes-frames.mdToolbar/menu ordering is host-controlled — items are sorted alphabetically by label. You cannot control ordering of toolbar items, object-settings items, calendar sources, or blocks in pickers. → 12-routes-calendars-adornments-settings.md
A toolbar item removed while the plugin is disabled comes back on re-enable — UNLESS someone saved the toolbar meanwhile. Items are filtered out of a saved layout at render time while the plugin is off, so re-enabling normally restores them. But the toolbar builder loads the already-filtered list into its editing state, so any save performed while the plugin is disabled persists a layout with your item deleted — permanently. Re-enabling does not re-inject it; the user must drag it back from the left column. Same for toolbar templates re-saved while disabled. → 11-output-ui-iframes-frames.md
There is no way to push a toolbar item to all users by default. A newly installed plugin's toolbar item appears in nobody's toolbar; defaults are only injected into never-modified layouts and contain no plugin entries. The only bulk path is an admin applying a toolbar template, which overwrites each recipient's whole toolbar. Don't rely on the toolbar as the discovery surface for a new feature. → 11-output-ui-iframes-frames.md
A
when-disabled or uninstalled artifact silently disappears from the UI. No error, no placeholder (record-layout blocks collapse entirely). When debugging a missing surface, check thewhenclause against actual saved config keys first. → 12-routes-calendars-adornments-settings.md
Modals, prompts & forms#
showViewInModalargs must nest underconfig.args— top-level args are silently dropped.this.showViewInModal("myview", { args: {...}, options: {...} }), nevershowViewInModal("myview", {...args}). → 10-views-modals-forms.mdshowViewInModalresolves{canceled, values, eventSource}at runtime — the TypeScript type ({canceled, result?, error?}) is stale. Readresult.values, and always guardresult.canceledfirst. → 10-views-modals-forms.mdForm values from views are ALWAYS array-wrapped;
dynamicPromptvalues are plain — the two result shapes are opposites.showViewInModalformData usesFormData.getAllsemantics:"Jane"→["Jane"], an empty-but-present text input is[""](truthy!), an unchecked checkbox group is absent. Unwrap withformData.key?.[0]?.trim()or join multi-values — indexing[0]silently drops extra values.dynamicPromptvalues are plain scalars (result.values.key), selects are the whole{label, value}option object, and unset selects / blank numbers are ABSENT fromvalues. → 10-views-modals-forms.mdMultiple forms in one view merge into one flat formData map — same key across forms, last form wins. Namespace input names per form if a view holds several. → 10-views-modals-forms.md
There is exactly ONE app-global modal slot — a modal opening another modal deadlocks/queues. Multi-step wizards are ONE frameless view that repaints itself via
outputUI: steps are event scripts, state rides in hidden inputs re-emitted on every repaint, and "Back" must be its own<form data-script>carrying the accumulated hidden inputs (a plain button click loses state). → 10-views-modals-forms.mdThe host Confirm button on a framed form view never runs your submit event script. It runs native constraint validation (
required,type=email,pattern) and collects all forms itself. Framed form views need zero eventScripts; a submit event script fires only on a real form submission (the frameless pattern with atype="submit"button). → 10-views-modals-forms.mdframeless: truestrips ALL host chrome — the view must close itself withthis.closeModal(values, canceled). Title, confirm, and cancel buttons are removed at the engine level (all-or-nothing). Wire your own footer buttons viadata-script. → 10-views-modals-forms.mdthis.promptis legacy — usethis.dynamicPrompt, and use the current key names. Items are keyed bykey(legacy usedid) and dropdowns aretype: "select"(the legacy"dropdown"type has no result-cleaning case indynamicPrompt— its value is silently dropped). → 10-views-modals-forms.mddynamicPromptcallbacks cannot close over your script's scope — they are serialized to strings.getFetchUrl/optionMapper/getHeaders/getBodyare stringified and executed elsewhere with({ state, args, utils }). Share helpers throughregisterUtilsand data throughargs, never through captured variables. → 10-views-modals-forms.mdrequired: trueIS host-enforced indynamicPrompt— script-side re-checks are dead code. The host blocks Confirm on blank required fields (it submits the raw untrimmed string — trim as normalization, not validation). Legacyprompt'srequiredwas documentation-only. → 10-views-modals-forms.mdprompt/showViewInModalresolve{canceled: true}when the host has no modal handler — indistinguishable from a user cancel. On surfaces where modals aren't wired (rare), you cannot tell "canceled" from "unsupported". → 10-views-modals-forms.mdopenCreateRelatedRecordModalis wired only on record-detail and floating-frame surfaces. Generic and calendar surfaces pass a no-op handler. → 04-worker-runtime-api.mdIn a wizard's final write step, don't close the modal on failure — repaint the previous step so the user can retry.
this.runEventScript("<prevStep>", { formData })repaints in place; for partial-cascade failures, rewrite the repainted state to reference already-created records first. → 10-views-modals-forms.mdStyle form validation with
:user-invalid, not:invalid.:invalidflashes required fields red before the user has touched them. → 10-views-modals-forms.md
Blocks & CSS#
Plugin blocks get NO host card chrome — transparent background, no border, no radius, no shadow — and are clipped by
overflow: hidden. Blocks must paint their own card, and inset it so a self-painted shadow isn't clipped (e.g.margin: 4px; height: calc(100% - 8px)with a tight shadow). The only host-provided shadow is the dashlet settings' drop-shadow toggle (rendered as a CSSfilter: drop-shadow). → 09-blocks.mdThe host injects a global
* { font-size: 10px; line-height: 1 }reset — em/rem are useless in plugin markup. Set explicit pxfont-size(and usuallyline-height) on everything. SVG<text>with px sizes inside a scaledviewBoxescapes the reset (it measures in user units) — the standard trick for chart labels that scale. Iframe surfaces are immune (separate document). → 09-blocks.mdBlocks use
styles.cssmapped to astyleskey; pages/views/frames usecss. Same mechanism, different key names — both are wrapped in a CSS@scope { ... }block, so write plain selectors; plugin CSS can't leak out, but host globals cascade in. → 09-blocks.mdDashboard/homepage blocks get NO record context — parse ids from the URL if you need them. Dashboard blocks receive
{dateFilter, teamFilter, objectId}; only record-surface blocks receive{objectId, entityId}. → 09-blocks.mdAn empty or missing
typesarray puts the block in EVERY picker (dashboards, homepages, charts, records). Settypesdeliberately. On record layouts the block renders at a fixed height (recommended_height, default 300px), not grid-resizable space. → 09-blocks.mdGrid size constraints are re-derived from the block definition on every load — never stored. Publishing new
min_w/max_w/min_h/max_hretroactively changes resize limits on existing dashlets. → 09-blocks.mdKeep one painter per block: a single
renderevent script owns all markup. The mount script paints a loading shell and delegates withthis.runEventScript("render"); every state-changing event script re-enters the same painter. Volatile state rides insessionData. Two painters drift. → 09-blocks.mdZero-progress arcs with round line-caps paint a stray dot — omit the arc entirely at zero. Chart trap in SVG donut/progress rings. Also avoid referenced gradient defs in strokes (fragile in modals) — use solid strokes with CSS drop-shadow glows. → 09-blocks.md
Navigation & communication#
Navigation context rides ONLY on relative URLs — absolute/cross-origin navigation silently drops the payload.
this.openWindow(url, target, context)stores context in sessionStorage and appends?session_data_key=; the mechanism requiresurl.startsWith('/'). Navigation still happens for other URLs — the context just never arrives. → 14-navigation-and-communication.mdThere is no
navigate/redirectmethod — in-app SPA navigation isthis.openWindow(relativeUrl, "_self"). Relative URL + target ≠_blankroutes through the host router; everything else is a realwindow.open. → 14-navigation-and-communication.mdNavigation context uses
JSON.stringifysemantics — circular refs and BigInt THROW in the worker; functions/undefined/symbols silently vanish. Keep context payloads plain JSON. Also:?session_data_key=lingers in the address bar after the destination consumes the context. → 14-navigation-and-communication.mdcallback.jscan NEVER fire fromthis.authorize()on a script page. Callback delivery requires the/plugins/callbackhelper page to load inside an iframe that is a child of the plugin-page tab (the iframe-type-page flow);authorize()opens a NEW TAB, and postMessage never crosses tabs. The authorize flow's completion signal is the marketplace Authorization panel (it polls), not your callback script. → 06-auth-secrets-services.mdCross-plugin dispatch does not exist —
runBlockScript/runFrameScriptare same-plugin, same-page only. Recipient matching requires the same plugin; a dispatch to an unmounted target is a harmless no-op (no error, no queue). → 14-navigation-and-communication.mdrunBlockScriptruns the event script in EVERY mounted instance of the target block. If the same block is placed three times on a page, the handler runs three times. Make handlers idempotent or key off instance state. → 14-navigation-and-communication.mdrunFrameScript/runBlockScriptargs are typed as flat scalars but JSON round-trip arbitrary objects. Objects work fine (the payload is stringified and merged into the target'sthis.args) — shape-check on receipt anyway. → 14-navigation-and-communication.mdsetSessionDatashallow-merges TOP-LEVEL keys only — writing{myMap: {k: true}}REPLACESmyMap. And hand-spreading nested maps is racy across overlapping workers (each spreads its own stale snapshot; last write wins wholesale). Correct pattern: one top-level session key per independent fact — the engine's top-level merge then composes concurrent writes race-free. → 14-navigation-and-communication.mdthis.sessionDatais a construction-time snapshot of OTHER workers' writes — but it does reflect your own.setSessionDataupdates the worker's local snapshot synchronously before posting, so re-readingthis.sessionDataafter your own write in the same run works. What you will not see is a write another worker made after your run began; for that, the value is frozen at construction. Session data is plugin-scoped, memory-only, and does NOT survive a tab reload. → 14-navigation-and-communication.mdWhen blocks sync via broadcast + persisted snapshot, never gate the live broadcast on completeness/validity. Broadcast exactly what the sender renders; gate only the persistence. Gating the broadcast makes receivers permanently miss "incomplete" (but rendered) states. → 14-navigation-and-communication.md
Services, auth & secrets#
Secrets are declared bare but read namespaced:
secrets["<plugin_api_name>__<secret_name>"]. A step declaring"secrets": ["api_key"]readssecrets["example_plugin__api_key"]. For repos whose api_name varies by environment, suffix-match:next((k for k in secrets if k.endswith("api_key")), None). → 06-auth-secrets-services.mdThe generic proxy WRAPS the upstream response — always unwrap
body. The proxy returns HTTP 200 with{status_code, response_headers, body}; from Python extractbody = resp.json().get("body") or {}and checkstatus_codeyourself. Some APIs additionally hide errors behind HTTP 200 in their own envelope — check the vendor's ok-flag too. → 06-auth-secrets-services.mdA disconnected/unauthorized OAuth service returns HTTP 503 from the proxy. Treat 503 as "not connected — send the admin/user to authorize", not as a transient outage. Calendar sources treat a 503 as an auth error automatically. → 06-auth-secrets-services.md
The proxy forwards exactly TWO caller headers upstream:
X-Proxy-Authorization→Authorization, andContent-Type. Everything else — includingAccept— is dropped and replaced withAccept: application/json. You cannot negotiate content types through the proxy. → 06-auth-secrets-services.mdno_authservices forward the caller'sX-Proxy-Authorizationverbatim — the secret is script-side. Unlike every other auth type, the credential lives in (and is visible to) your script. Preferbasic_auth_token_providedwith an integration secret so the proxy injects it server-side. → 06-auth-secrets-services.mdA static stored secret with a custom Authorization scheme (e.g.
Authorization: ApiKey <key>) is not expressible today.basic_auth_token_providedalways emitsBasic <token>. If the vendor needs a custom scheme, the current options are ano_authservice + script-side header, or a token-exchange auth type if the vendor supports it. → 06-auth-secrets-services.mdEncrypt manifest secrets with
npx --yes @kizenapps/cli encrypt— plaintext values still work but are legacy and discouraged. The envelope{"encrypted": true, "value": "<base64>"}goes anywhere a secret lives inkizen.json(e.g.services[].auth_credentials.client_secret); the publish pipeline decrypts server-side. Notenpx --yes @kizenapps/cli encrypttargets production keys by default — pass--stage devexplicitly for dev-key encryption. → 06-auth-secrets-services.mdChanging a service's OAuth credentials or scopes invalidates stored tokens — every business/user must re-authorize. Credential changes flip installs to ERROR until re-auth; base-URL changes are seamless. Plan scope additions as a re-auth event. → 16-release-and-publish.md
{{secret.KEY}}templates inservices[]that reference an unfilled/undeclared secret make the proxy return 400. Every templated key must exist inbase_config.secretsand hold a value for the business. → 06-auth-secrets-services.mdScripts never see OAuth tokens, and there is NO secrets API in browser workers. Token storage/refresh is entirely server-side behind the proxy. Secrets are readable only inside Python Agentic Workflow steps (the
secretsdict). A JS surface that needs an authed external call must go through a declared service. → 06-auth-secrets-services.mdRelative-URL worker requests run as the ACTING USER — there is no plugin service identity in the browser.
this.get("/records/...")uses the signed-in user's permissions; a plugin's service-account identity exists only for Python steps. Design permission expectations accordingly. → 06-auth-secrets-services.mdService
scoperestricts callers by identity class — the wrong caller gets 403.service-account-onlyallows only the plugin's own service account (Python steps);user-account-onlyis the inverse (browser surfaces). Unknown scope strings deny everyone. → 06-auth-secrets-services.mdManifest-level
success_redirect_path/error_redirect_pathon services are effectively dead config. First-party callers pass redirect paths as query params, which take precedence. Set redirects at thethis.authorize(serviceName, {successRedirectPath, errorRedirectPath})call site. → 06-auth-secrets-services.mdthis.authorize()is fire-and-forget void — the outcome is unobservable from the script. It opens the authorize URL in a new tab and resolves nothing. Poll your own "connected" signal (e.g. a cheap proxied GET that 503s when disconnected) or rely on the marketplace Authorization panel. → 06-auth-secrets-services.mdPresigned/CDN download URLs must BYPASS the proxy. Some CDNs reject requests carrying an injected
Authorizationheader. Fetch presigned URLs directly (plainfetchin workers,urllib/requestswithout the proxy in Python). → 06-auth-secrets-services.mdPer-account-URL providers (each tenant has its own base URL) don't fit a static
base_service_url. Services are declared once per fixed base URL. For per-install hosts, put connection details in a JSON integration secret (the DB-connector pattern) or declare one service per known host. → 06-auth-secrets-services.md
Calendar sources#
Calendar schema validation REPORTS but does not FILTER — malformed entries render anyway. The host's schema check is wrapped in a try/catch that routes the failure to
onErrorand then returns your array unchanged, so a bad entry reaches the calendar UI regardless. Filter invalid entries in your own script. Calendars:[{id, name, description?, default?}]. Events:[{id, calendar_id, title, start_time, end_time, description?, url?, activity_id?, all_day?, busy?, attendees?}]. → 12-routes-calendars-adornments-settings.mdEvent times are epoch-millisecond NUMBERS, not ISO strings. Use
this.formatDateForResponse(date)(=date.getTime()). ISO strings fail validation. → 12-routes-calendars-adornments-settings.mdAll-day dates must be parsed as LOCAL midnight —
new Date("YYYY-MM-DD")parses as UTC and shifts a day across DST/timezones. Usethis.createDateObject(dateStr)for date-only values; usenew Date(dateTime)only for full timestamps with offsets. This is the #1 source of off-by-one-day calendar bugs. → 12-routes-calendars-adornments-settings.mdOn any fetch error, log and
return []— degrade to "no events" instead of tearing the source down. A thrown error breaks the whole calendar list for the user. → 12-routes-calendars-adornments-settings.mdThe calendar worker blocks UI-ish APIs at CALL time, not parse time.
uploadFile,installThirdPartyScript,refreshEntityForId,openCreateRecordModal,openCreateRelatedRecordModal,showViewInModal,completeSetupall reject with "not supported in calendar source scripts" — so an un-awaited call fails silently.closeModalalone throws synchronously.prompt/dynamicPromptremain available. → 12-routes-calendars-adornments-settings.mdThe query range (
range_start/range_end) is day boundaries in the USER's timezone, with offsets. Formattedyyyy-MM-dd'T'HH:mm:ssXXX. Pass them through to the provider encoded (encodeURIComponent), don't re-derive boundaries. → 12-routes-calendars-adornments-settings.mdMulti-select user-config values are
{label, value}option objects — map.valuebefore comparing to provider ids. A calendar filter list built from raw option objects never matches anything. → 13-setup-assistants.mdFollow the provider's pagination in production sources. Demo sources that ignore
nextPageToken-style cursors silently drop events on busy calendars. → 12-routes-calendars-adornments-settings.md
Agentic Workflow steps & actions#
An unmapped optional input is ABSENT from
inputs— attribute access raisesAttributeError, it is notNone. Alwaysgetattr(inputs, "name", None)for optional inputs. → 07-automation-steps.mddata_typemust be a VARIABLE type, not a field type — wrong values publish fine and then break the builder. The valid enum (10 values):string, boolean, number, date, datetime, email, phone_number, employee, entity, uuid. Field-type names (text,integer,decimal,money,files) publish without error, render a broken field dropdown ("No Options"), and fail at Agentic Workflow save with"X" is not a valid choice. Usenumberfor numerics;filesis genuinely unsupported as a step param. → 07-automation-steps.mdallowed_valueson static inputs is stripped server-side at publish and never reaches the builder. The workflow author sees a free-text control, not a picker, and nothing validates their input against your list. Scripts must handle out-of-set values explicitly. → 07-automation-steps.mddefaulton a step input is dropped at publish too. Same fixed field set. It does not pre-fill the builder's control and does not substitute a value at run time, so an input you expected to default arrives as whatever the empty control produced. Apply defaults in the script. → 07-automation-steps.mdThe authored
api_name(published asaction_step_api_name) is the step's real identity —action_typeis dead andscript_aliasis vestigial. Steps resolve by(plugin_app, action_step_api_name). The Python runtime bindsinputs.<name>by the param'sname, neverscript_alias; a mismatched alias is harmless, a renamedapi_namebreaks every wired workflow. → 07-automation-steps.mdoutput_targetis a phantom key — silently dropped at publish.input_sourceis used for both inputs and outputs. → 07-automation-steps.mdemployee/entityinputs arrive as bare id scalars (UUIDs), not objects. A team-member input arrives asuuid.UUID; resolve details viakizen.api.get(f"/team/{id}").phone_numberarrives as an E.164 string. → 07-automation-steps.mdStep execution is hard time-limited (55 s / 1 GB) — size retry budgets to fit inside it. 55 seconds is the platform's hard kill, not a target: design to roughly 30 seconds so a retry still has room. Cap any single
Retry-Aftersleep and raise with an actionable message instead of blowing the budget. → 07-automation-steps.mdPython steps CAN call declared plugin services — through the proxy path, not a helper.
kizen.api.post("/external-integrations/proxy/<plugin_api_name>/<service_name>/<path>", ...). There is nogetServiceUrlin Python. → 07-automation-steps.mdbusiness_plugin_configis a builder-selectable input source you cannot pre-declare. It isn't in the publish enum; at runtime it injects the clean config as a JSON STRING under the input's name —json.loadsit. → 07-automation-steps.mdconnection_secret_tagis a plugin-side convention, not a platform field. It's an ordinary step input whose value selects a nested key inside a JSON-valued secret. Server-side, the whole JSON blob is one integration secret. → 07-automation-steps.mdUsers paste secrets from rich-text docs — normalize smart quotes before
json.loads. Translate“ ” ‘ ’ ‚ „to straight quotes or JSON parsing fails on visually-correct input. → 07-automation-steps.mdDB steps execute
inputs.queryverbatim — SQL injection is the caller's problem, and read/write must be split. Give the read step a regex guardrail (^\s*(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|GRANT|REVOKE|COPY|CALL|DO)\b) plus a session-level read-only mode; document that the write step has no guardrail. → 07-automation-steps.mdMinor/patch republishes auto-apply to RUNNING workflows; majors require explicit upgrade. Step config resolves to the latest version within the installed major at read time. A removed or renamed step hard-fails at run with "config not found"; most other drift (stale inputs, type changes) is silent. → 16-release-and-publish.md
A raised exception's message is exactly what the workflow run history shows — make it actionable. Prefix with the vendor and tell the user what to check ("Check your workspace connection in the plugin setup assistant"). → 07-automation-steps.md
JS action
config.jsonis only{name, api_name, hint_object_name}— everything else is install-time association config.include_perform_action(adds the action to Perform Action menus) lives on the action-template↔object association created at install/setup time;hint_object_namemerely pre-selects the object. Neither is akizen.jsonfield. → 08-actions.mdA create-override needs TWO rows — the object setting alone is silently inert. (a) an action-template↔object association (created by the marketplace Setup Assistant or
POST /external-integrations/browser-js-action-template-association), and (b) the object'saction_override_createset to the composite key"{plugin_api_name}.{action_api_name}"(dot-separated). Missing (a) means "+ Add" silently falls back to the native form with no error. Wrong key format = same silent fallback. → 08-actions.mdThe create-override script must return the new record's id as a NON-EMPTY STRING.
return String(created.id). A non-string result is ignored and a bare return no-ops — the host links nothing. Also handle the bail-out path:await this.openCreateRecordModal(objectId)then returnundefinedto fall back to the native form. → 08-actions.mdDuplicate association creates are rejected with a message containing "already exists." — match the text and treat as success. The error
codeis not on the wire. Idempotent wiring scripts must string-match (and rememberError.messagedoesn't surviveJSON.stringify). → 08-actions.mdIn a relationship-add override,
this.objectIdis the RELATED object's id, not the host record's. Host-record context isthis.entityId/host ids; the action-target ids (actionObjectId/actionEntityId) are empty when invoked as a plain record action. → 08-actions.mdBulk "Perform Action" for plugin JS actions is only wired on the record DETAIL page today. List-page multi-select bulk execution of plugin actions is not wired in the host. Don't promise list-page bulk behavior. → 08-actions.md
A data adornment's return value is DISCARDED. Adornments are fire-and-forget: mutate via the API, then
this.refreshEntity()to repaint. Any reference code reading an adornment's return value is wrong. → 12-routes-calendars-adornments-settings.mdAdornment
valueshapes are per-field_type — and adatetimevalue is always an ISO 8601 string. The icon only renders when the field has a value, sovalueis never null/empty; the{label}option-object shape occurs only forfield_type: "date". Defensive shape-checks for impossible shapes are vestigial. → 12-routes-calendars-adornments-settings.mdPhone extensions serialize with an
xsuffix (+15555550123x123) — a naive digit-strip fuses the extension onto the number. Split on the firstxand emit RFC 3966:tel:+15555550123;ext=123. → 12-routes-calendars-adornments-settings.mdObject-settings items run with
objectIdonly —currentEntity()is undefined.entityIdis coerced to''; don't depend on an entity. The return value is discarded here too. → 12-routes-calendars-adornments-settings.mdRoute scripts:
blocking: trueALWAYS releases when the script settles —releaseBlockingScript()is only for releasing EARLY. Normal return and thrown error both release (the engine's cleanup runs unconditionally). UsingreleaseBlockingScriptas a hang-guard is redundant; only a never-settling script hangs the page. Noteblockingand the object binding are install-time settings, notconfig.jsonfields. → 12-routes-calendars-adornments-settings.md
Release & publish#
Every release push must bump
kizen.jsonversionAND addreleaseNotes/<version>.md— together, in the same commit. The version must strictly increase versus the base branch (PR check), the backend rejects duplicate versions (400), and the notes file matching the version becomes the marketplace/release body. Treat the notes file as required practice even though the packager doesn't enforce it. → 16-release-and-publish.mdChanging or removing a plugin's
api_namein a PR is hard-blocked — it is the plugin's permanent identity. Renaming an action/step api_name is nearly as bad: associations and wired workflows key on it as plain text and dangle silently. → 16-release-and-publish.mdPublish is a wholesale replace — a new immutable version row, no diff/upsert. Non-
0.0.0versions are immutable; the0.0.0dev version is delete-and-recreate (a destructive overwrite of the previous dev build). → 16-release-and-publish.mdPreview (PR) deploys mutate identity: version forced to
0.0.0,published: false, api_name suffixed per branch. This is why hardcoded api_names 404 in previews and why preview installs are unlisted. → 16-release-and-publish.mdpublished: falsedoes not mean private — it means unlisted. The plugin is still published and installable by URL/api_name; it just doesn't appear in the marketplace listing. → 16-release-and-publish.mdrequired_entitlementgates visibility, not just installs. Non-entitled businesses don't see the plugin in the marketplace at all; installs 400; direct retrieval 404s; revoking the entitlement triggers a full async uninstall. Only the latest published version's value is evaluated. → 16-release-and-publish.mdInstalls auto-track minor/patch releases within the installed major — a "small" release ships instantly to every install. There is no gradual rollout. Anything behavior-changing belongs in a major (which requires each business to explicitly upgrade). → 16-release-and-publish.md
base_config.force_open_sourceis a debugging flag — never ship it. Open-source status is derived from the source repository's visibility at publish time. → 16-release-and-publish.mdTreat
kizen.jsonas sensitive whenever any plaintext secret remains in it. Artifact scripts run client-side and should be treated as open source; the manifest'sservices[]credentials are the exception. Encrypt them (npx --yes @kizenapps/cli encrypt) before making a repo public, and audit the FULL git history — a clean HEAD is not a clean repo; rotate anything ever committed. → 16-release-and-publish.mdOpen-sourced Kizen plugins conventionally ship GPL-2.0 — don't default to MIT/Apache. The ecosystem convention is copyleft, so derivative plugins stay open; match it unless you have a reason not to. → 16-release-and-publish.md