Build, install, and run your first Kiro Crew app in 5 minutes.
- Kiro Crew installed and running (
kirocrew gateway) - Node.js 22.12+ (24 LTS recommended) (for apps with UI)
Use the CLI scaffold so the manifest, opaque 512×512 placeholder icon, agent, skill, and UI build files agree with the current schema:
kirocrew app init my-dashboard --uimy-dashboard/
├── app.json ← App manifest (required)
├── assets/icon.png ← Replace the generated store icon before publishing
├── agents/
│ └── sample-agent.json ← Agent definition
├── skills/
│ └── sample-skill/
│ └── SKILL.md ← Skill knowledge file
├── ui/ ← Frontend (if app has UI)
│ ├── package.json
│ ├── vite.config.ts
│ ├── src/App.tsx
│ └── .gitignore
└── README.md
Every app needs an app.json. See Manifest Reference for all fields.
The scaffold writes no permissions block. Add the one below: the sample page
calls api.get('/api/status') and listens for notification events, and the
SDK rejects a path or event the manifest does not declare.
{
"name": "my-dashboard",
"version": "0.1.0",
"displayName": "My Dashboard",
"description": "A Kiro Crew app: My Dashboard",
"author": "yourname",
"iconPath": "assets/icon.png",
"agents": ["agents/sample-agent.json"],
"skills": ["skills/sample-skill"],
"ui": {
"entry": "dist/index.mjs",
"pages": [
{
"route": "/apps/my-dashboard",
"label": "My Dashboard",
"icon": "Package"
}
]
},
"permissions": {
"api": ["/api/status"],
"events": ["notification"]
}
}Edit ui/src/App.tsx. Your app is a standard React component that uses
@kirocrew/app-sdk hooks and @kirocrew/app-sdk/ui shared components.
You do not
npm install@kirocrew/app-sdk. The dashboard host provides it (and React, ReactDOM, lucide-react) at runtime through its import map: the bare@kirocrew/app-sdkspecifier resolves to the host's vendored copy viawindow.__kirocrew_modules. This guarantees your app shares the host's exact React instance (so hooks work) and stays a small bundle. Mark these as externals in your build (don't bundle them).
import { useAppApi, useAppEvents } from '@kirocrew/app-sdk'
import { Card, CardTitle, PageHeader, StatCard } from '@kirocrew/app-sdk/ui'
import { useState, useEffect } from 'react'
export default function MyDashboard() {
const api = useAppApi()
const [data, setData] = useState(null)
useEffect(() => {
api.get('/api/status').then(setData)
}, [])
// Listen to real-time events
useAppEvents('notification', (event) => {
console.log('New notification:', event)
})
return (
<>
<PageHeader title="My Dashboard" subtitle="Custom app page" />
<div className="px-6 pb-8 overflow-y-auto flex-1 min-h-0">
<div className="grid gap-3.5 grid-cols-[repeat(auto-fit,minmax(150px,1fr))] mb-6">
<StatCard label="Status" value={data ? 'Online' : '...'} accent />
</div>
<Card>
<CardTitle>Content</CardTitle>
<p className="text-sm text-muted">Your app content here.</p>
</Card>
</div>
</>
)
}Edit agents/sample-agent.json to customize your agent:
{
"name": "my-agent",
"model": "auto",
"description": "Analyzes data and generates reports",
"prompt": "You are a data analyst assistant.",
"tools": ["@kirocrew-core"]
}Edit skills/sample-skill/SKILL.md to teach your agent domain knowledge.
cd my-dashboard/ui
npm install
npm run buildThis produces dist/index.mjs — the ESM bundle loaded by the dashboard.
Install and enable with the CLI:
kirocrew app install /absolute/path/to/my-dashboardBefore you enable it, trust the app. Enable refuses every app that is not
built in, even one that ships only an agent and a skill, until the operator
trusts it. Enabling it from the App Store asks you to trust this app alone
(agent.apps_trusted). Settings → Security → Third-party apps lists and revokes
those grants, and holds the switch that trusts all third-party apps
(agent.apps_allow_third_party set to true). See
Publishing Guide.
kirocrew app enable my-dashboardThe REST routes require dashboard or app authentication, so do not replace these
commands with a bare curl. You can also open the Kiro Crew dashboard → App Store
and install from a local path. The enabled app then appears in the sidebar.
During development:
- Edit
ui/src/App.tsx. - Run
cd ui && npm run build. - Use the installed app's Update action in the authenticated App Store UI.
- Refresh the dashboard.
For UI live reload, follow App Dev Mode. Agent and skill changes take effect on the next agent invocation (no UI rebuild needed), but the installed copy still has to be updated unless the relevant source directory is linked through the documented dev-mode workflow.
Available in @kirocrew/app-sdk:
| Hook | Purpose |
|---|---|
useAppApi() |
Permission-scoped JSON-response HTTP client (request, GET/POST/PUT/PATCH/DELETE); request options and errors: API reference |
useAppEvents(event, cb) |
Subscribe to real-time WebSocket events |
useTheme() |
Reactive theme (mode, accent, colorTheme) |
useAppInfo() |
App metadata (name, version, permissions, active, update) |
useNavigate() |
Navigate to Kiro Crew routes |
useNotify() |
Show toast notifications |
useNavBadge() |
Update sidebar badge count |
useChatLauncher() |
Open a new chat or target slotKey; set autoSend: false for an unsent draft |
useAppInfo().active tells your app whether its host surface is the one the user is
looking at. A routed ui.pages page is always the visible surface, so it reads true.
A contributes.panelTabs side-panel tab is different: it stays MOUNTED while hidden —
behind another tab, with the panel closed, or while another chat is active — so that
switching back does not discard your component's state. Poll on an interval, hold a
global hotkey, or run an animation loop and it keeps costing while nobody can see it.
const { active } = useAppInfo()
useEffect(() => {
if (!active) return // hidden: do not start the interval at all
const id = setInterval(refresh, 5000)
return () => clearInterval(id)
}, [active])Treat undefined as true: the field is optional, so an app running on a host that
predates it must still render rather than assume it is hidden.
useAppInfo().update is how your app finds out that a newer version of itself has
been published, so you can show your own "update available" row instead of waiting
for the user to visit the store.
const { version, update } = useAppInfo()
if (!update) return null // nothing checked yet — say nothing
if (!update.available) return null // checked, you are current
return <Banner>Version {update.latestVersion} is out (you have {version}).</Banner>The three states are deliberately distinct, and the first two are easy to confuse:
update |
Means |
|---|---|
| absent | The host has not consulted anything. You do not know you are current. |
available: false |
The host checked and nothing newer is published. |
available: true |
latestVersion is newer than your installed version. |
Render an absent update as "nothing to say", never as "up to date" — the second
claims something no one has verified.
checkedAt is when the host's cache was written, as an ISO-8601 instant, or null
if it could not be read. The host answers from caches it already keeps and never
fetches on your behalf, so the answer can be hours old; show checkedAt if the age
matters to your user. A fresh answer arrives when the store's listing refreshes.
The field reads the same on every surface your app mounts on — a routed ui.pages
page and a contributes.panelTabs side-panel tab both get it — so you write the
banner once.
The field is advisory. It tells you what to display, not what to do: your app cannot update itself from it, and the gateway re-resolves the app's source when an update is actually applied.
There is no "you were just updated" event, on purpose. An update is applied from
Discover or the app's detail page, where your app is usually not mounted, so a
live signal would reach the code that needs it least. Compare instead, on mount:
remember the last version your app ran, and act when useAppInfo().version
differs from it.
const { version } = useAppInfo()
useEffect(() => {
const last = localStorage.getItem('my-app:last-version')
if (last !== version) {
migrateIfNeeded(last, version) // last is null on a first run
localStorage.setItem('my-app:last-version', version)
}
}, [version])This runs in the NEW bundle, whenever it first mounts, however long after the update that is. Keep the stored version wherever the rest of your app's state lives — a backend can do the same comparison on start against its own data.
Also in @kirocrew/app-sdk, for an app that renders agent messages itself. An agent puts follow-up
choices and steer acknowledgements inline in its prose ([OPTIONS: a | b],
[STEERING steer-<id>: …]); these parse them out so your UI can show buttons instead of raw syntax.
The functions and types are listed in api-reference.md, with worked examples. The module is React-free, so it also works in a worker or a test.
To render a transcript rather than parse one, ChatMessageList draws it and its row
registry lets you add a row type or replace one without forking the list — including the
roles the dashboard leaves undrawn. See
api-reference.md.
Available in @kirocrew/app-sdk/ui:
Card, CardTitle, Btn, SendBtn, Input, SearchInput, Badge,
SourceBadge, StatCard, Skeleton, ContentSkeleton, EmptyState,
PageHeader, Toggle, InfoTip, SegmentedControl, MarkdownRenderer,
Clickable, Modal, ErrorNotice, SettingsSection, SettingsCard,
SettingsInput, SettingsToggle, SettingsSelect, Select, guideCaution
The main SDK exports useImeGuard so app-owned inputs reuse the host's IME
Enter protection. It also exports activeLocale, fmtNumber, fmtCurrency,
fmtDate, fmtTime, fmtDateTime, fmtRelative, compareText and
useLanguageGeneration. Subscribe with the hook when a memoized component
needs to repaint on language changes. Apps own their translation catalogs;
these helpers share locale selection and formatting, not permission to modify
the dashboard's catalogs.
For chat handoff, openChat({ message, autoSend: false }) creates an unsent
draft in a new session. Add slotKey to target an existing session; a draft
launch appends to that session's unsent text. Omit
autoSend to send automatically; without slotKey this starts a new session.
agent applies only to new sessions. A target must activate successfully before
its message is used; the SDK does not select another session on failure.
memoryMode ('persistent', 'incognito' or 'temporary') and
sessionFolder: false shape a new session and are ignored with slotKey.
Either one opens a new draft session, like autoSend: false, so the message is
staged for the user to send.
To tell an older host from one with these seams, read APP_SDK_LEVEL through a
namespace import (import * as sdk from '@kirocrew/app-sdk'; an older host has
no such export, so sdk.APP_SDK_LEVEL is undefined there). A named import of a
name the host lacks fails the whole bundle at load time.
For Python app hooks with an existing cron grant, use
await ctx.cron.set_enabled_async(job_id, False) to pause an owned job, or
True to resume it without replacing its ID. Use set_enabled off-loop.
update_job and update_job_async reject enabled and user_paused arguments;
use the toggle methods instead. Foreign and missing job IDs are refused.
The add_job* methods accept approval_mode, timeout_secs and timeout at
create time. Pass approval_mode="auto" for an unattended agent job: the
default, "", is hook-based approval, so every tool call in the job's session
waits on a prompt nobody is present to answer. Set it on the create call rather
than in a follow-up update_job — the job is live from its first save, so a
second write leaves a window in which a due-scan can run it under the wrong
mode, and add_job_if_absent_async returns None once the name exists, which
skips that follow-up on every registration after the first. timeout_secs is
the per-wake execution budget (1..86400 seconds, default 1800) and timeout
bounds only a script or command subprocess (default 30s for a script, 300s for
a command). On a script or command job set the pair together: the create path
refuses a wake budget that cannot cover the subprocess timeout plus cleanup,
because a shorter budget cancels the executor future while the subprocess keeps
running and the next wake launches a duplicate.
Externalize @tanstack/react-query when bundling your app. The dashboard import
map resolves that specifier to the host's module instance, so useQuery,
useMutation and useQueryClient work with no provider of your own. Do not
bundle a second copy of React Query. Use useAppApi() inside query and mutation
functions to retain scoped transport and host session binding.
The module is shared; the cache is not. useQueryClient() returns a client the
host creates for your app, so your keys, clear() and invalidateQueries() reach
only your app's cache, and dashboard keys are neither readable nor writable from
your bundle. That client is kept per app AND per host session for as long as the
dashboard page lives, so returning to your page under the same host surface reuses
the same cache rather than a fresh one. Your app mounted in two places bound to
different sessions -- its own page and a chat side panel, or two panels -- gets one
cache each, so a key you write in one never answers a read in the other. An
individual query inside it still expires on React Query's own gcTime once
nothing observes it, which is the default five minutes unless your query sets a
longer one; the dashboard's thirty-minute app retention window is registered for
builtin pages only. Dashboard data is available through useAppApi() under the
paths your app.json declares.
Declare what your app can access in app.json:
{
"permissions": {
"api": ["/api/crons", "/api/status"],
"events": ["notification", "slots"],
"mcpTools": ["cron_add", "cron_list"],
"storage": true,
"cron": true,
"network": false
}
}The App SDK checks declared permissions before each request — accessing undeclared paths throws an error.
- Backend communication: Your dashboard UI can call your app's backend through the gateway reverse proxy at
/apps/{name}/api/*— no CORS issues. Verify requests in your backend withverify_proxy_request()fromkiro_crew.apps.proxy_auth. - See App Manifest Reference for all
app.jsonfields - See API Reference for TypeScript and Python client APIs
- See Publishing Guide for publishing to the App Store registry
Python apps, CLI tools and services use the source-only kirocrew-client
package from a Kiro Crew checkout. Install steps, the constructor and the full
method table are in API Reference.
Once your app works locally, publish it to the App Store registry so other
Kiro Crew users can install it with one click. Your app's code stays in a
dedicated GitHub repository listed in the official registry
(kirodotdev/KiroCrewApps); Kiro Crew accepts no new built-in app, so an app is
never added to the Kiro Crew package itself.
See Publishing Guide for the full workflow.