kirocrew-app-dev
kirodotdev/KiroCrew/skills/kirocrew-app-dev/SKILL.md
Build, package, and publish Kiro Crew external apps. Covers app.json manifests, UI components, crons, skills, lifecycle hooks, development mode, registry publishing, and common pitfalls.
Skill4.2k starsChanged 32 days ago
---
name: kirocrew-app-dev
description: "Build, package, and publish Kiro Crew external apps. Covers app.json manifests, UI components, crons, skills, lifecycle hooks, development mode, registry publishing, and common pitfalls."
---
# Kiro Crew App Development
Guide for building external Kiro Crew apps against the current App Kit manifest, lifecycle, UI SDK, and registry contracts. The canonical field and API references live in [App Kit](../../docs/app-kit/README.md).
## When to Use
- User says "create a kirocrew app", "build an app", "make an app for kirocrew"
- User says "package this as an app", "publish this to the app store"
- User is building UI pages, crons, or skills that should be distributable
## App Structure
```
my-app/
├── app.json # Manifest (required)
├── ui/
│ ├── index.mjs # ESM React component (default export)
│ └── icon.svg # Sidebar icon (SVG or PNG)
├── skills/
│ └── my-skill/
│ └── SKILL.md # Skill spec
├── scripts/
│ ├── install.sh # Optional registry-install setup hook
│ └── uninstall.sh # Cleanup script
└── README.md # Optional docs
```
## app.json Manifest
```json
{
"name": "my-app",
"displayName": "My App",
"version": "1.0.0",
"description": "What it does in one sentence.",
"author": "login",
"tags": ["relevant", "tags"],
"skills": [
"skills/my-skill"
],
"crons": [
{
"name": "my-cron",
"message": "Run the scheduled app workflow.",
"every": 900,
"silent": true,
"persistent_session": false
},
{
"name": "market-open",
"message": "Summarise the overnight tape.",
"cron_expr": "30 9 * * 1-5",
"timezone": "America/New_York",
"skip_dates": ["2026-12-25"]
}
],
"permissions": {
"api": ["/api/apps/my-app/config", "/api/chat", "/api/chat/*"],
"mcpTools": ["local_knowledge_search", "send_message"],
"network": true,
"cron": true
},
"ui": {
"entry": "index.mjs",
"pages": [
{
"route": "/apps/my-app",
"label": "My App",
"iconUrl": "icon.svg"
}
]
},
"setup": {
"onInstall": "bash scripts/install.sh",
"onUninstall": "bash scripts/uninstall.sh"
}
}
```
### Critical Rules
| Field | Rule | Why |
|-------|------|-----|
| `skills` | Prefer canonical string paths `["skills/my-skill"]` | Legacy object entries with `path` or `name` are normalized by `AppManifest.from_dict`, but the public manifest schema is `string[]` |
| `permissions` | Must be an **object**; supported grants are `api`, `events`, `mcpTools`, `storage`, `network`, `memory`, `cron`, `sessionApproval`, `spawn`, `jobs`, and `exposeToApps` | A non-object parses to empty permissions, and malformed capability grants fail closed |
| `resources` | Omit from `app.json`; declare app resources with `agents`, `skills`, `sops`, and `mcpServers` | `resources` is install/registry ownership metadata (`"gateway"` or `"app"`), not an app-manifest resource array |
| `ui.entry` | Use an ESM `.mjs` bundle; `.js` is also served | HTML is not in the app UI static-file allowlist |
| `ui.pages[].iconUrl` | Use an app-relative image path for a custom icon | `ui.pages[].icon` also works for supported host Lucide names and falls back to `Package` |
| `displayName` | Required | Gateway uses it for UI display |
| `version` | Semver string | Used by the App Store registry to compute `updateAvailable` |
| `crons[].timezone` | IANA zone name; omit it only when the hour is zone-agnostic | An empty timezone falls back to the gateway config's zone and then to **UTC**, so `"cron_expr": "0 6 * * *"` without it fires at 06:00 UTC — the wrong calendar day for most users. A per-USER zone is not manifest data: pass `timezone=` to `ctx.cron.add_job` instead |
| `crons[].skip_dates` | Zero-padded `YYYY-MM-DD`, evaluated in `timezone` | `"2026-1-1"` parses but never matches the padded fire-time rendering, so the skip silently does nothing. Both fields are rejected at manifest validation, not at fire time |
## Lifecycle and Resource Registration (CRITICAL)
Do not make a cron mutate the installed app tree or create skill links. When an
app is enabled, the gateway registers its declared agents, skills, SOPs, MCP
servers, and crons; gateway startup reconciles enabled app resources again.
Lifecycle scripts have explicit scope:
- A local-path `kirocrew app install <dir>` copies the app but intentionally does
not run `setup.onInstall`; build the local source before installing it.
- A registry install runs `setup.onInstall` in the cloned source before copying
it into the data home. Keep that hook idempotent.
- `setup.onEnable` / `onDisable` run around enablement, and `onUninstall` is only
for external state Kiro Crew cannot remove itself.
Keep runtime data under the app's `data/` directory. The gateway owns registered
resource links and scheduler entries; app code must not recreate them manually.
## UI Development
### Design System
Kiro Crew apps should default to the host visual language. Colors come
from the theme tokens (`var(--accent)` and friends) — see "Don't Reinvent the
Dashboard" below, which is the normative rule. The hex values here are the legacy
palette, kept for apps that predate the tokens and as the fallback inside
`var(--accent, #7c3aed)`; a hardcoded hex breaks every custom palette, so reach for
one only when a deliberate custom style is the point.
| Element | Style |
|---------|-------|
| **Styling method** | Prefer `@kirocrew/app-sdk/ui` components and theme tokens; CSS, host utility classes, or inline styles are all valid. |
| **Primary accent** | `var(--accent)` (legacy: `#7c3aed`) |
| **Light accent bg** | legacy `#e8d5f5` |
| **Success color** | `#047857` (green) |
| **Warning color** | `#b45309` (amber) |
| **Danger color** | `#b91c1c` (red) |
| **Buttons** | `borderRadius: '9999px'` (full pill), font 11px weight 500 |
| **Badges** | `borderRadius: '9999px'`, 10px bold, colored bg+text |
| **Cards** | `background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: '6px', padding: '14px'` |
| **Max width** | `maxWidth: '1200px'` |
| **Font sizes** | 10px (version/badges), 11px (body/buttons), 12px (table), 13px (section headers), 18px (title) |
| **Theme vars** | `var(--bg)`, `var(--text)`, `var(--muted)`, `var(--border)`, `var(--card)` |
**Example badge:**
```javascript
_jsx('span', {
style: { background: '#e8d5f5', color: '#7c3aed', padding: '2px 7px', borderRadius: '9999px', fontSize: '10px', fontWeight: 600, letterSpacing: '0.02em' },
children: 'LABEL'
})
```
**Example primary button:**
```javascript
_jsx('button', {
style: { background: '#7c3aed', color: '#fff', border: 'none', padding: '5px 14px', borderRadius: '9999px', fontSize: '11px', fontWeight: 500, cursor: 'pointer' },
children: 'Action'
})
```
**Example secondary/ghost button:**
```javascript
_jsx('button', {
style: { background: 'transparent', color: '#7c3aed', border: '1px solid #e8d5f5', padding: '5px 14px', borderRadius: '9999px', fontSize: '11px', fontWeight: 500, cursor: 'pointer', whiteSpace: 'nowrap' },
children: '↻ Refresh'
})
```
### Reference Implementation
For a working example, use `docs/app-kit/examples/`, which contains installable
external-app patterns. Builtins under `src/kiro_crew/apps/builtins/` are useful
for UI idioms, but their backend and lifecycle contracts differ from external
apps.
Visual mock of the target style:
```html
<!--
Legacy geometry reference for a Kiro Crew app header and card.
Use host components and theme tokens instead of copying the hardcoded colors.
-->
<div style="max-width:1200px; margin:0 auto; padding:16px; font-family:system-ui; color:#e2e8f0; background:#1a1b26">
<!-- Header -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px">
<div style="display:flex; align-items:center; gap:10px">
<span style="font-size:18px; font-weight:600">Review Tender</span>
<span style="background:#e8d5f5; color:#7c3aed; padding:2px 8px; border-radius:9999px; font-size:10px; font-weight:600">Every 15 min</span>
</div>
<div style="display:flex; align-items:center; gap:10px">
<span style="font-size:11px; color:#6b7280">Last scan: 3m ago</span>
<button style="background:transparent; color:#7c3aed; border:1px solid #e8d5f5; padding:5px 14px; border-radius:9999px; font-size:11px; font-weight:500">↻ Refresh</button>
<span style="font-size:10px; color:#6b7280">v1.7.0</span>
</div>
</div>
<!-- Card -->
<div style="background:#1a1b26; border:1px solid #2d2f3d; border-radius:6px; padding:14px; margin-bottom:12px">
<div style="font-size:13px; font-weight:600; color:#7c3aed; margin-bottom:8px">Open Reviews Being Tended (2)</div>
<!-- Table row example -->
<div style="display:flex; align-items:center; gap:8px; padding:6px 0; border-bottom:1px solid #2d2f3d; font-size:12px">
<a style="color:#7c3aed; text-decoration:none">#1234</a>
<span style="font-size:11px; max-width:180px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">Fix manifest dict format</span>
<span style="background:#fef3c7; color:#b45309; padding:2px 7px; border-radius:9999px; font-size:10px; font-weight:600">iterating</span>
<span style="margin-left:auto">
<button style="background:transparent; color:#7c3aed; border:1px solid #e8d5f5; padding:5px 14px; border-radius:9999px; font-size:11px; font-weight:500">💬 Chat</button>
</span>
</div>
</div>
<!-- Update banner -->
<div style="background:#e8d5f5; color:#7c3aed; padding:8px 14px; border-radius:9999px; font-size:12px; display:flex; justify-content:space-between; align-items:center">
<span>Update available: v1.6.0 → v1.7.0</span>
<button style="background:#7c3aed; color:#fff; border:none; padding:5px 14px; border-radius:9999px; font-size:11px; font-weight:500">Update Now</button>
</div>
</div>
```
Key visual rules from the mock:
- Dark theme uses `var(--bg)` / `var(--border)` / `var(--text)` — don't hardcode dark hex
- Light purple badge for metadata (`#e8d5f5` bg, `#7c3aed` text)
- Amber status pills (`#fef3c7` bg, `#b45309` text)
- Ghost buttons with purple text on light purple border
- Version as plain muted text (smallest element, 10px)
- Cards have 14px padding, 6px radius, 12px bottom margin
### Entry Module Pattern
```javascript
import { useState, useEffect } from 'react'
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from 'react/jsx-runtime'
import { useAppApi, useAppInfo, useNavigate } from '@kirocrew/app-sdk'
export default function MyApp() {
const [state, setState] = useState({})
const [loading, setLoading] = useState(true)
const api = useAppApi()
const { version: appVersion } = useAppInfo()
const navigate = useNavigate()
useEffect(() => {
async function load() {
try {
setState(await api.get('/api/apps/MY-APP/config'))
} finally {
setLoading(false)
}
}
load()
const interval = setInterval(load, 30000) // 30s polling
return () => clearInterval(interval)
}, [api])
// ... render UI
}
```
### Version Source (IMPORTANT)
Read the installed manifest version from `useAppInfo()`. The host mounts external
apps inside `AppApiProvider` and supplies `{name, version, permissions, active}`;
apps are not iframes and do not need to derive absolute data-home paths.
```javascript
const { version: appVersion } = useAppInfo()
```
Do not cache a version in `data/config.json`, hardcode a user-specific install
path, or bypass the scoped SDK with `/api/file-read` just to read `app.json`.
### Header Layout Pattern
All apps use a consistent header: Icon + Title + badge on the left, last-scan + refresh + version on the right.
```javascript
_jsxs('div', {
style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' },
children: [
_jsxs('div', { style: { display: 'flex', alignItems: 'center', gap: '10px' }, children: [
// Inline SVG icon (same as ui/icon.svg, rendered at 20px with purple stroke)
_jsx('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 20, height: 20, viewBox: '0 0 24 24',
fill: 'none', stroke: '#7c3aed', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
children: [/* your icon paths */] }),
_jsx('h2', { style: { margin: 0, fontSize: '18px' }, children: 'My App' }),
_jsx('span', { style: { background: '#e8d5f5', color: '#7c3aed', padding: '2px 8px', borderRadius: '9999px', fontSize: '10px', fontWeight: 600 }, children: 'Every 15 min' })
]}),
_jsxs('div', { style: { display: 'flex', alignItems: 'center', gap: '10px' }, children: [
_jsx('span', { style: { fontSize: '11px', color: 'var(--muted)' }, children: lastScan }),
_jsx('button', { /* refresh - see below */ }),
_jsx('button', { /* version pill - see below */ })
]})
]
})
```
**Inline icon rules:**
- Render the same SVG from `ui/icon.svg` directly in JSX (20x20px, `stroke: '#7c3aed'`)
- Use `_jsx('svg', {...})` with child `_jsx('path', { d: '...' })` elements
- Don't fetch the icon file at runtime — inline it for instant render
### Background Actions (MANDATORY)
**All buttons that trigger agent work MUST run in the background** through the permission-scoped `useAppApi()` client and `POST /api/chat?ws=1`. NEVER navigate to `/chat` for automated actions — the user should stay on the app page.
```javascript
// Refresh button (background)
_jsx('button', {
disabled: refreshing,
onClick: async () => {
setRefreshing(true)
await api.post('/api/chat?ws=1', {
message: 'Run the workflow...',
slot: 'my-app-refresh'
}).catch(() => {})
setTimeout(() => setRefreshing(false), 5000)
},
style: { background: 'transparent', color: refreshing ? 'var(--muted)' : '#7c3aed', border: '1px solid #e8d5f5', padding: '4px 10px', borderRadius: '9999px', fontSize: '11px', fontWeight: 500, cursor: refreshing ? 'default' : 'pointer' },
children: refreshing ? '↻ Running…' : '↻ Refresh'
})
// Version link (host-managed update details)
_jsx('button', {
onClick: () => navigate('/apps/detail/my-app'),
title: 'Open App Store details',
style: { background: 'none', color: 'var(--muted)', border: 'none', padding: '2px 6px', fontSize: '10px', cursor: 'pointer' },
children: `v${appVersion || '?'}`
})
```
**Only use `navigate('/chat')` for actions that genuinely need human involvement** (responding to reviewer comments, complex setup wizards).
### UI Rules
| Do | Don't |
|----|-------|
| Prefer host components and theme tokens | Assume inline styles or Tailwind are mandatory |
| Use `useAppApi()` with an app-owned route or config endpoint | Bypass the permission-scoped SDK with raw `fetch` |
| Use `useNavigate()` from `@kirocrew/app-sdk` for navigation | Use `window.location` (causes full reload) |
| Use theme vars for backgrounds/text/borders | Hardcode hex for theme-dependent colors |
| Use theme tokens (`var(--accent)`) for accent elements | Hardcode `#7c3aed` outside a `var(--accent, …)` fallback |
| Read version from `useAppInfo()` | Read version from `data/config.json` or derive an absolute install path |
| Run background work through `useAppApi().post('/api/chat?ws=1', ...)` | Navigate to `/chat` for automated actions |
| Show a loading/error state when config is unavailable | Show cryptic error messages |
| Read app state through an app-owned API or `/api/apps/MY-APP/config` | Derive arbitrary absolute data-home paths in UI code |
### Available Gateway APIs
| Endpoint | Method | Returns |
|----------|--------|---------|
| `/api/apps/MY-APP` | GET | Full manifest JSON through authenticated, permission-scoped app access |
| `/api/apps/MY-APP/config` | GET/PUT | App-owned JSON config; declare it in `permissions.api` |
| `/api/project/tree` | GET | Active-project tree when declared in `permissions.api` |
| `/api/chat?ws=1` | POST | Launches agent work in background slot |
| `/api/file-watch` | GET (SSE) | Live file change stream (avoid — see above) |
`/api/project/tree` lists the active project when the app declares that API
permission. For an app-owned data directory, keep a bounded `_index.json` instead
of asking for arbitrary filesystem traversal.
### Available Imports (via shared import map)
- `react` (useState, useEffect, useRef, etc.)
- `react/jsx-runtime` (_jsx, _jsxs, Fragment)
- `react-dom`
- `@kirocrew/app-sdk` — hooks (`useAppApi`, `useAppEvents`, `useTheme`, `useAppInfo`,
`useNavigate`, `useNotify`, `useNavBadge`, `useChatLauncher`, `useChatSession`), the
`ChatEmbed` / `ChatPanel` / `ChatMessageList` components, the transcript's row
**registry** (`defaultMessageRenderers`, `mergeRenderers`, `resolveRenderer`,
`ToolCallPill`), and the chat **marker protocol** (`parseOptions`,
`deriveFollowUpOptions`, `extractSteeringAcks`, `stripPartialOptionMarker`). The protocol
is React-free, so a worker or a plain function can use it too. The registry is how you add
a transcript row type or replace one instead of hand-rolling a message list — see
`docs/app-kit/api-reference.md`.
### Interactive Elements (Chat Launch)
For actions requiring human interaction, use the supported launcher rather than
writing the host's private global directly:
```javascript
const { openChat } = useChatLauncher()
openChat({ message: 'Your prompt here', autoSend: false })
```
### Interactive Widgets (data-action)
For button callbacks within mcwidgets rendered by the agent (not app UI):
```html
<button data-action="approve" data-payload='{"id":"123"}'>Approve</button>
```
User receives: `[UI] approve: {"id":"123"}`
## Cron Design
### Notification Deduplication
Never ping the user for the same unchanged condition. Track what was communicated:
```json
"last_notified": {
"event": "new_comment",
"at": "2026-05-19T17:00:00Z",
"details": "soopra: Can you explain..."
}
```
Before sending any DM:
1. Check `last_notified.event` and `last_notified.details`
2. If same → SKIP
3. If different → SEND and update `last_notified`
### Silent by Default
Set `"silent": true` in cron config. Use `send_message` only for a genuine new development the user needs to act on; do not hardcode `session="slack"` unless the app is explicitly Slack-only.
### Cron Message Structure
Keep the cron prompt focused on the scheduled work. Resource registration and
lifecycle setup belong to the gateway and declared hooks, not shell bootstrap
commands embedded in an agent prompt.
## Background work and downstream services
A cron is the one thing your app does to somebody else's service with no user
watching. Get the cadence wrong and the owner of that service sees load from
every install at once, cannot tell which app it is, and fixes it the only way
they can: throttling. That breaks your app, not just its polling.
### Declare static crons in the manifest; use CronSDK for dynamic schedules
Put install-wide schedules in the `crons` array of `app.json`. The gateway
reconciles that array through `register_app_crons_with_service`
(`src/kiro_crew/apps/bridges.py`) at enable and gateway startup. Registration is
add-if-absent under one store lock, keyed by cron name.
A schedule derived from user configuration may instead use `ctx.cron` / CronSDK;
those jobs remain app-namespaced and are removed by app teardown. Never write the
cron store directly. Declare `permissions.cron` for either form, and keep dynamic
schedule configuration visible in the app UI and README.
### Cadence: a 5-minute floor, push over polling
| Rule | Why |
|------|-----|
| Never more often than every 5 minutes when the job calls a third-party or shared service, and never per-minute polling | Every install runs the same schedule, so the service absorbs installs × frequency and has no channel to ask you to slow down |
| Prefer a webhook, changelog, event feed or push subscription wherever the service offers one | A poll asks "did anything change" a thousand times to learn "no". Push tells you once, when it did. `register_hook` is the callback side of this |
| Poll only where no push path exists, and then at the coarsest cadence the feature tolerates | A stale panel is a smaller cost than an app the service owner has to block |
**A 5-minute schedule gets no jitter from the platform, so add your own.**
`_compute_jitter` (`src/kiro_crew/cron.py`) returns `0.0` for `every` under
3600s and for any `cron_expr` whose minute field contains `/`, `,` or `*`;
only hourly schedules get spread (0 to 5 minutes) and daily ones (0 to 59).
Nothing spreads a 5-minute job, so anything that makes it overdue on many
machines at once (a released app update, a gateway restart, a fleet coming back
after an outage) fires it immediately on all of them and lands as one
synchronised burst on the service.
So spread it yourself. Pick one stable offset per install, uniform in
`[0, interval)` — hash the install id, or write a random value once into your
state file — and reuse that same number every tick. A fixed offset per install,
not a fresh one per run: a stable offset keeps each install's schedule
predictable while pulling the fleet apart, and survives the restart that would
otherwise re-align everybody. Bounding it below the interval is what stops a
hashed offset from pushing a 5-minute job past its own next tick.
Apply it as a **phase offset your own due-check honours**: read the offset, and
treat the tick as not-yet-due until `now` has passed that offset inside the
current window. Sleeping the offset at the top of the tick reaches the same fire
time, but it is the worse form — a multi-minute sleep holds the run's session
open for most of the interval — so keep the sleep for an offset of a few seconds
and use the phase offset for anything longer.
### Bounded concurrency
The scheduler will not overlap a job with itself: the due-scan skips any job
that holds a run claim (`is_running`, `src/kiro_crew/cron.py`), so a slow run
delays its own next tick rather than doubling up. What is not bounded is what
you do inside one run. There is no per-app cap on sessions or subagents today;
per-app quotas are still a draft phase in
`docs/request-for-change/rfc-app-sandbox-isolation.md`. So a tick that fans out
one agent session per work item is your bug, paid for by the user's machine and
the service you called.
- One in-flight run per cron, and let it finish.
- Fan out over a small fixed batch per tick, not over the whole result set.
- Keep a cursor in your state file so the next tick continues instead of
restarting the sweep.
### Backoff and a circuit breaker
- Retry `429` and `5xx` with exponential backoff, and honour `Retry-After` when
the service sends it. Do not retry any other `4xx`: it will fail identically
forever.
- Persist consecutive failures in your state file and stop calling after a
threshold. An in-memory counter is useless here, because each cron run is a
fresh process; only the state file survives to the next tick.
- Re-probe an open breaker at most once per interval, and show the tripped state
in the UI so the user knows why the panel is stale.
### Identify the app on outbound calls
Send an app-specific `User-Agent`, or fill in whatever client-name field the API
offers: `my-app/1.2.0 (kirocrew-app)`. Load a service owner cannot attribute is
load they can only throttle wholesale, and an app that is identifiable is one
they can contact instead of block. Where a service has both a public API and a
browser-private one, use the public one.
### Tell the user before they click setup
The anti-pattern is a single Setup button that silently installs a per-minute
cron against a shared service. Before any background work starts, the UI must
state:
- what will run, one plain line per cron, not the cron expression
- how often
- which service it calls, and whose credentials it uses
- how to turn it off
Repeat it in the README and the store description. The store card is where most
users actually decide.
### Disable and uninstall must stop everything
Disable removes an app's cron jobs, but only when the manifest declares the
`cron` permission: the disable path in
`src/kiro_crew/apps/hooks_integration.py` gates cleanup on `permissions.cron`
being truthy. That gate is a platform gap, tracked in
[issue #10997](https://github.com/kirodotdev/KiroCrew/issues/10997) and recorded
here at symptom level because the fix belongs in the gateway, not in a skill.
Until it lands, an app
that schedules work without declaring that permission keeps firing after the
user disables it, which is the worst outcome in this whole section: load with no
owner and no off switch.
- Declare `permissions.cron` if you ship any cron at all.
- Keep `permissions` truthful both ways. Declare what you use, drop what you no
longer use. Grants are read at the gateway boundary, never inferred from your
code, so an over-broad block is a real grant and a missing one is a real
failure.
- `scripts/uninstall.sh` must always stop background work and remove anything
the app registered outside its own directory. **User state is a separate
decision and is not yours to make.** Uninstall preserves app data by default
(`keep_data = True` in `src/kiro_crew/apps/routes.py`, flipped only by an
explicit `purge_data` request), and that choice reaches your script as
`PURGE_DATA` / `KEEP_DATA` in its environment. So delete state files only
under `PURGE_DATA=1`, and leave them untouched under `KEEP_DATA=1`. A script
that deletes unconditionally destroys data the user asked to keep, with no
recovery path.
- Verify by hand: disable the app, then confirm none of its jobs remain on the
Schedule page.
### Reviewer checklist
- [ ] Every schedule lives in `app.json` `crons`; no handler writes to the cron store.
- [ ] No schedule fires more often than every 5 minutes against a third-party or shared service, and none polls per minute.
- [ ] Polling is used only where the service offers no webhook, changelog or push.
- [ ] A 5-minute or sub-hourly schedule carries its own per-install offset; the platform adds none. Read it off the diff rather than trusting the box: `app.json` declares the sub-hourly `every`, the state file schema holds an offset key, and the tick reads that key before its first outbound call. A schedule under 3600s with no offset in state fails this line.
- [ ] Each tick has bounded fan-out; no session-per-item.
- [ ] Backoff on `429`/`5xx` plus a circuit breaker persisted in state.
- [ ] Outbound calls carry an app-specific identifier.
- [ ] The UI names what runs, how often, and against which service before setup.
- [ ] `permissions.cron` declared; disable leaves no jobs; uninstall stops all background work and deletes user state only under `PURGE_DATA=1`.
- [ ] Manifest `permissions` match what the code actually uses.
## Publishing to App Store
### 1. Git Repository
Publish the app as a plain git repository (any git host — e.g. GitHub):
- Include: `app.json`, `ui/`, `skills/`, `scripts/`
- Any git-cloneable URL works (`https://github.com/<org>/<repo>`, `git@host:...`, `ssh://...`)
### 2. App Registry Entry
Open a pull request to the Kiro Crew repo adding an entry to
`src/kiro_crew/apps/app-registry.json`:
```json
{
"name": "my-app",
"gitUrl": "https://github.com/<org>/my-app",
"branch": "main"
}
```
`gitUrl` alone resolves. `repo` is a legacy alias for the same clone URL and is
only read when `gitUrl` is absent; it is never a slug.
That file is the offline seed. The live App Store reads `official-registry.json`
from the hosted catalog at `https://apps.crew.kiro.dev/`, so a listing lands there
too, alongside its editorial and category-order files.
Display metadata (description, tags, author) comes from `app.json` in the app's own repo (cached 24h).
### 3. Updates
Bump `version` in the app repository and publish it. The App Store enriches the
registry row with `installedVersion` and `updateAvailable`, then performs updates
through its authenticated Update action (`POST /api/apps/{name}/update`).
### Host-Managed Update Pattern
Do not add an app-owned update-check cron or run `git archive --remote` against
GitHub. The registry already compares semantic versions and the gateway owns the
clone, admission, lifecycle, rollback, and resource re-registration steps. Link
users to the App Store when an update action is needed.
## Self-Update & Refresh Pattern
Use a background slot for app-specific refresh work, but leave app package updates
to the App Store. An agent prompt must not replace the gateway's authenticated
update transaction.
### Background Slot Pattern
For any action that should run without navigating away from the app page, use `POST /api/chat?ws=1` with a named slot:
```javascript
api.post('/api/chat?ws=1', {
message: 'Do the thing...',
slot: 'my-app-action-name'
})
```
- The slot is created if it doesn't exist, reused if it does
- Response is JSON `{ok: true}` — the work runs async in the background
- Results surface via state file changes picked up by the 30s polling cycle
- Use a disabled/spinner state to show feedback while in flight (5-10s timeout)
## Installation Flow (User Perspective)
1. `kirocrew app install /path/to/my-app` copies a local directory containing `app.json`; a registry install is initiated from the App Store and performs the clone.
2. `kirocrew app enable my-app` grants activation and registers declared resources. A normal install/enable does not require a gateway restart.
3. The enabled app page appears in the sidebar when `ui.pages` is declared.
4. Manifest crons register with the scheduler; local installs intentionally skip `setup.onInstall`, while registry installs run it before copying the app.
5. Use `kirocrew app dev my-app` during UI iteration instead of restarting the gateway.
## .gitignore (IMPORTANT)
Apps generate local install artifacts that must NOT be committed to the repo:
```
/build
/release-info
.app_secret
app-crons.json
installed.json
data/
```
The `data/` directory contains machine-local runtime config and user state. Do not store the app version there; `useAppInfo()` supplies the installed manifest version.
## Git Workflow for Installed Apps
The installed directory under `~/.kiro/crew/apps/` is deployment output, not a
Git workspace: the safe copy deliberately omits `.git`. Edit and commit in the
source repository, then use the App Store Update/Sync action (or reinstall from
the local source during development). Never commit or push from the installed
copy.
## Common Pitfalls
| Pitfall | Cause | Fix |
|---------|-------|-----|
| "no visual interface" in sidebar | Missing `ui.entry` or wrong extension | Use `.mjs` ESM with default export |
| Skills do not load after enable | Declared path is missing/escaping, or the app is still disabled | Use canonical string paths and inspect `skill_search`; the gateway owns namespaced and flat links |
| UI or manifest changes do not appear | The installed copy is a snapshot of the source | Use App Store Sync/Update, or documented app dev mode for UI changes |
| `onInstall` does not run for a local-path install | Local installs intentionally skip the registry build hook | Build locally before install; registry installs execute `setup.onInstall` |
| SSE overwrites React state | `/api/file-watch` fires immediately on connect | Use polling instead |
| Cron spams DMs | No dedup — same condition triggers every cycle | Track `last_notified` in state |
| App icon falls back to `Package` | Unsupported `ui.pages[].icon` name or bad `iconUrl` | Use a supported host Lucide name or a valid app-relative image path |
| App changes disappear after update | Editing the installed snapshot or a dev symlink that safe-copy replaces | Edit the source, update/sync, and recreate any explicitly granted dev link |
| Version shows "?" or an old number | Version was cached outside the host-provided app context | Read `useAppInfo().version` |
| Install artifacts in git | `data/`, `.app_secret`, etc tracked | Add to `.gitignore`, `git rm --cached` |
| Buttons navigate away from app | Using `navigate('/chat')` for automated work | Use permission-scoped `useAppApi().post('/api/chat?ws=1', ...)` |
| Package update bypasses App Store safeguards | Asking an agent slot to rewrite the installed app | Use the authenticated App Store Update/Sync action |
| Cron keeps firing after the user disables the app | App schedules work but never declares `permissions.cron` — the disable path gates cron cleanup on that grant | Declare `permissions.cron`, then verify disable leaves no job on the Schedule page |
| Every install hits the same service in the same minute | Sub-hourly schedules get zero jitter, so a rollout or restart syncs them | Add a stable per-install offset in your own state file, or go hourly or coarser and let the platform spread it |
| Downstream service starts throttling the app | Unattributable polling load from every install | A 5-minute floor with no per-minute polling, push/webhook instead of polling, app-specific `User-Agent` |
| One cron tick spawns dozens of agent sessions | Fan-out per work item, with no per-app quota to stop it | Bounded batch per tick plus a cursor in the state file |
## Testing Locally
1. Build the app in its source directory.
2. Install: `kirocrew app install /path/to/my-app`.
3. Enable: `kirocrew app enable my-app`.
4. Verify the UI, declared cron jobs on the Schedule page, and the namespaced skill through `skill_search`.
5. For UI iteration, enable `kirocrew app dev my-app`; no gateway restart is required.
## Versioning Convention
- Patch (1.0.x): Bug fixes, wording changes
- Minor (1.x.0): New features, new crons, UI additions
- Major (x.0.0): Breaking changes to state format, removed features
## In-Process Backend for External Apps (CRITICAL — differs from builtins)
External (installed) apps CAN ship a Python backend that runs inside the gateway
process — but the contract DIFFERS from builtins (`auto_research` etc.):
- Manifest: use ONLY `backend.hooks` (`"routes": "backend.routes:register_routes"`).
Do NOT set the `backend.routes` base-path string — that field triggers the
STANDALONE-PROCESS proxy, which serves dead stubs that shadow your handlers.
- `register_routes(ctx)` receives an AppContext and MUST return `list[AppRoute]`
(`from kiro_crew.apps.route_registry import AppRoute`) with paths RELATIVE to
`/api/apps/<name>`; `{params}` land in `request.match_info`.
- Handlers take `(request, ctx)`; gateway state is `request.app["state"]`;
auth-check `request.get("user") is not None` → else 401.
- The builtin pattern (direct `app.router.add_get`) silently never dispatches for
external apps — the RouteRegistry catch-all (`/api/apps/{app_name}/{path:.*}`)
shadows it.
- Backend hook changes need a gateway restart OR an app disable→enable cycle
(runtime deregister + module unload + fresh load). UI files reload without.
- Trust: backend code runs UNSANDBOXED with full gateway privileges (SEC-012
warning logged; `agent.apps_allow_third_party=false` refuses it entirely).
## Dev Loop for App UIs
- **Preferred: dev mode** — `kirocrew app dev <name>` (off: `--off`). Serves that
app's UI with `Cache-Control: no-store` and watches its `ui/` dir; changes
broadcast `app_reload` and the dashboard hot-swaps the app in ~1s. The flag
lives in `installed.json` and toggles live.
- To edit in your source tree, symlink the installed UI dir to source, then
explicitly grant that out-of-install root:
`mv ~/.kiro/crew/apps/<n>/ui ~/.kiro/crew/apps/<n>/ui.bak && ln -s <src>/ui ~/.kiro/crew/apps/<n>/ui`
followed by `kirocrew app dev <n> --confirm-out-of-install-root`. That
confirmation is a human security decision; agents must not supply it.
On native Windows use a directory junction instead (PowerShell):
`Rename-Item "$env:USERPROFILE\.kiro\crew\apps\<n>\ui" ui.bak; New-Item -ItemType Junction -Path "$env:USERPROFILE\.kiro\crew\apps\<n>\ui" -Target "<src>\ui"`
(`pathlib` resolves junctions the same way, so serving and the watcher work;
the lifecycle clobber below applies identically — junctions are also never
preserved by the install/update safe-copy).
- **⚠️ Symlinks do NOT survive the app lifecycle.** `install_app`/`update_app`
(reinstall, App Store Update, registry refresh) re-copy source over the
installed dir with a DELIBERATE symlink-stripping safe-copy (security: blocks
`ui -> ~/.docker` style serving). Your symlink is silently replaced by a
frozen snapshot: hot reload stops, UI goes stale, no error. Symptom:
`ls -l ~/.kiro/crew/apps/<n>/ui` shows a real dir, not a link. Fix: re-create
the symlink after ANY install/update, and re-check dev mode is still on.
- Same clobber applies to locally edited shipped skills: installed skill files
under `~/.kiro/crew/skills/` re-sync from the Kiro Crew package on update.
Durable bundled-skill changes belong in `src/kiro_crew/builtin_skills/`;
top-level `skills/` is checkout-only guidance.
- Validate `.mjs` before relying on a reload: `node --check ui/index.mjs` —
a parse error surfaces only as "Failed to load <App>: Unexpected token".
- Avoid deep `_jsx` nesting in one expression; prefer small named components.
- Dark mode: never pair a solid light accent bg with hardcoded dark text for
selected states — use a translucent accent tint (e.g. `rgba(124,58,237,.14)`)
with `var(--text)`/`var(--muted)`. Self-contained pills (own bg+fg) are fine.
## Don't Reinvent the Dashboard (default posture)
By DEFAULT, apps should look and behave like the dashboard they live in:
- **Theme tokens over hardcoded colors**: `var(--accent)`, `var(--accent-fg)`,
`var(--accent-subtle)`, `var(--danger)`/`var(--danger-subtle)`, `var(--ok)`,
`var(--bg)`/`var(--card)`/`var(--border)`/`var(--text)`/`var(--muted)`.
Hardcoded hex breaks the moment a user picks a custom palette (and error
banners hardcoded for light mode glow in dark mode). Give tokens fallbacks
(`var(--accent, #7c3aed)`) so old hosts still render.
- **Host components over hand-rolled ones**: the `@kirocrew/ui` module-map
export ships `Btn, Input, SearchInput, Badge, Toggle, EmptyState, Skeleton,
ContentSkeleton, PageHeader, SegmentedControl, MarkdownRenderer` and more;
`lucide-react` ships a subset of real icons. Feature-detect
(`window.__kirocrew_modules?.['@kirocrew/ui']`) and keep a small fallback for
old hosts — a thin wrapper per component (host when available, fallback
otherwise) keeps call sites clean.
This is the default, **not a straitjacket**: if your app has a deliberate,
preferred custom style or a novel interaction with no host equivalent (bespoke
visualizations, a branded look, domain-specific widgets), a custom design is a
legitimate choice — make it consciously and consistently, not as an accident of
copy-pasted inline styles. Custom visuals should still respect the theme's
background/text tokens so they don't break light/dark/custom palettes.
## Embedded Chat (ChatEmbed) — native chat inside your app
The host SDK ships the dashboard's real chat renderer. Use it instead of
hand-rolling a transcript view — markdown, tool activity, streaming, and turn
grouping come for free and stay consistent with the main chat.
- Access: `const sdk = window.__kirocrew_modules?.['@kirocrew/app-sdk']`, then
render `sdk.ChatEmbed` with `{ slotKey, agent?, placeholder? }`. Feature-detect
and keep a lightweight fallback — the module map can lag one gateway version.
- `slotKey` binds the embed to a chat slot (`<app-name>-<entity>` is the
convention). The embed polls `/api/chat/slots/<key>` (1s while running, 5s
idle) and POSTs to `/api/chat`.
- **Manifest permissions (silent-failure trap):** the SDK gates fetches by the
app's `permissions.api` allowlist. ChatEmbed needs `"/api/chat"` and
`"/api/chat/*"`; its Approve/Trust controls use the slot-scoped
`/api/chat/slots/{slot}/approve` route covered by that wildcard.
- Chrome and scroll are props, not CSS overrides. Pass `frameless` to drop the
bordered card, title strip and input-row border so the embed sits flush inside
your own card, and `startAtBottom` to jump to the newest turn immediately and
stay pinned there (released when the user scrolls up more than 40px, re-pinned
when they return). Do NOT reach for `!important` overrides on the embed's
Tailwind classes or hand-roll a scroll keeper: those couple you to host DOM
internals the repo has never promised.
- Permission cards render inside ChatEmbed and route Approve, Reject, and Trust
through the slot-scoped approval endpoint. Keep attended worker slots
interactive instead of granting blanket trust merely to avoid a dead card.
- Rendering agent messages yourself instead of using `ChatEmbed`? An agent puts
follow-up choices and steer acknowledgements inline in its prose
(`[OPTIONS: a | b]`, `[STEERING steer-<id>: …]`). Parse them with the SDK's
marker protocol rather than by hand, and remember the rule that costs users
their input: stripping a marker WITHOUT offering the affordance deletes the
choices outright — worse than showing the raw text.
## Worker Slots — apps that own agent sessions
**Stopgap — tracked in issue #509** (a supported `acquire_worker_slot(app,
project, trust=…)` helper): these are underscore-private slot internals, not a
promised API. Until #509 lands they are the only mechanism, but treat this
recipe as scaffolding — re-check it against the SDK when you update an app.
If your app creates chat slots for background/worker agents (spec writers,
researchers), stamp these attributes — and re-stamp on EVERY acquisition, not
just creation, because gateway restarts and other code paths (e.g. ChatEmbed's
own POST) can recreate slots without them:
- `slot._app = "<app-name>"` — keeps the session out of the main chat sidebar.
- **Trust — grant it BOUNDED, never blanket-forever.** Attended workers can use
ChatEmbed's approval cards. An unattended worker still needs a deliberately
scoped grant rather than a permanent one:
- *Preferred:* pattern-scoped trust via `slot._trusted_patterns` (supported
by `chat_runner`) — allowlist only the tool/command shapes your worker
actually needs.
- *If you must use blanket `slot._trust = True`:* time-box it. Mirror the
in-repo precedent (`auto_research`: 24h TTL, then trust expires and
re-authorization re-grants it) rather than re-stamping `True`
unconditionally forever. A permanent unscoped auto-approve worker silently
exempts a growing class of sessions from the interactive-approval layer —
a security regression that compounds as apps adopt the pattern.
- Always SEL-audit the grant, whichever form it takes.
- `slot.project = <working_dir>` — sets the CLI process cwd (chat_runner runs
`cwd=slot.project`). Without it the agent prefixes every command with
`cd <long-path> && …`, which turns every tool pill in the transcript into
identical truncated noise; with it, commands are relative and readable, and
the worker inherits project-scoped steering files.
## Positioning — your app is NOT in an iframe
App UIs mount directly into the dashboard DOM. `position: fixed` therefore
escapes your panel and covers the ENTIRE dashboard (sidebar, header). For
overlays/modals scoped to your app: set `position: relative` on your app root
and use `position: absolute; inset: 0` for the overlay.
## Backend Change Ergonomics
- UI hot-swaps in ~1s (dev mode); backend hooks load only on gateway restart or
an app disable→enable cycle. Batch backend edits and plan one reload.
- Validation loop: `python3 -m py_compile backend/routes.py` then copy to the
installed dir — it takes effect on the NEXT reload, silently. Track what's
pending.
- Verify the served UI module actually updated before debugging "my change
doesn't work": `curl -s <gateway>/apps/<name>/ui/index.mjs | md5sum` vs
`md5sum <src>/ui/index.mjs`. A mismatch means clobbered symlink or dev mode
off.
- Probe a backend route without auth plumbing: an auth-gated route returning
401/403 proves it is REGISTERED; 404 means the module didn't load.
## Graduating an External App to a Builtin
When an app proves out and should ship with Kiro Crew, port it into the repo —
the contracts CHANGE on both sides. Template: `src/kiro_crew/apps/builtins/issue_radar/`.
- Layout: `src/kiro_crew/apps/builtins/<snake_name>/` with `app.json`,
`backend/routes.py`, optional `skills/<skill>/SKILL.md`, `tests/`.
- **Backend contract flips**: builtins use `register_routes(app: web.Application) -> None`
registering FULL paths (`/api/apps/<name>/…`) directly on the router — the
external AppRoute-list/RouteRegistry contract does not apply. Wrap every
handler in an enabled-check gate (see issue_radar's `_require_enabled`):
builtin routes exist at startup even while the app is disabled.
- **Wiring**: in-process builtin backends must be listed in
`BUILTIN_NAMES` (`apps/builtins/__init__.py`) — that startup loop is what
calls `register_routes`. (Subprocess-backend builtins like dev_fleet use
`backend.entryPoint` + port instead and are NOT listed.) App Store discovery
is separate and automatic via `discover_builtin_apps()` scanning `app.json`.
- **UI becomes a real React page**: `website/src/apps/<name>/…Page.tsx`
registered in `website/src/apps/builtinRegistry.ts` (lazy import). You now
import MarkdownRenderer, lucide-react, the ui kit, and app-sdk components
directly — delete the module-map feature detection and CSS override hacks.
Note ChatEmbed requires an `AppApiProvider` ancestor; builtin pages mount
their own.
- Icon/assets: `website/public/app-assets/<name>/`; manifest `iconUrl`
`/app-assets/<name>/icon.svg`. Skills ride `manifest.skills` (paths relative
to the app root), registered at enable-time.
- Keep `defaultEnabled: false`; users opt in via the App Store.
- Full worktree + build-gate discipline applies (see the kirocrew-worktree-dev
skill) — this is now Kiro Crew source.
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

