Yapay Zeka

KiroCrew App Guide: From Blank Page to Live Dashboard

Build a KiroCrew app with manifest, React dashboard, agents, skills, permissions, dev mode, and the blank-page debugging rules that matter fast.

İlker Ulusoy 2026-08-09 9 min read dk okuma

This KiroCrew app guide is the field report I wish I had before shipping helloworld: a real app with a sidebar page, a bundled agent, and a skill. It covers the manifest, the React bundle, the host-provided SDK, permissions, dev mode, and the blank-page debugging rule that saves the most time.

The hardest part was not writing React. It was learning which contracts are actually part of a KiroCrew app and which ones only belong to a standalone agent config. I first built the wrong thing: a workspace agent with an agentSpawn hook that printed Hello World before the first turn. That worked, but it was not an app. It had no sidebar route, no manifest version, and no package that another operator could install.

The Short Version

A KiroCrew app is a directory with an app.json manifest. It can ship a dashboard page, agents, skills, and crons. The UI is a plain React component compiled to one ESM file. The dashboard host provides React, ReactDOM, lucide-react, and the app SDK at runtime, so those packages must stay external. Install with kirocrew app install, then iterate with kirocrew app dev plus a symlink so onenpm run build becomes the whole loop.

What A KiroCrew App Actually Is

A KiroCrew app is the packaging unit. An agent config gives you one selectable agent. An app can include that agent and also add a dashboard page, app metadata, skills, scheduled jobs, and a lifecycle that can be installed, enabled, disabled, and published. If you want a sidebar page or a versioned thing another person can install, you want an app.

QuestionAgent configKiroCrew app
Lives in~/.kiro/agents/<name>.json~/.kiro/crew/apps/<name>/
ShipsOne agentPage, agents, skills, crons
Registered bykirocrew agent createkirocrew app install
Appears inAgent dropdownSidebar, App Store, agent dropdown
VersionedNoYes, through app.json
DistributableCopy one filePackage or one-click registry install

That distinction sounds small until the first dashboard request arrives. A workspace-scoped agent can run a hook. It cannot make a sidebar page appear. The app manifest is what gives the host a route, a label, an icon, a permission envelope, and a set of files to copy into the app directory.

The Directory Shape

Scaffold with kirocrew app init before writing anything by hand. I manually built the first version, then ran the official scaffold as a sanity check. That one comparison validated the externals list and showed four component signatures that I would otherwise have guessed. Free validation is worth taking.

my-dashboard/
├── app.json                    # manifest: the only required file
├── agents/
│   └── sample-agent.json       # agent definitions
├── skills/
│   └── sample-skill/
│       └── SKILL.md            # domain knowledge for the agent
├── ui/
│   ├── package.json
│   ├── vite.config.ts
│   ├── src/App.tsx
│   └── dist/index.mjs          # build output loaded by the host
└── README.md

The app root is the contract. The host does not care how you author the UI, only that ui.entry points to a built module that default-exports a React component. It does not care how you draft the skill, only that the manifest points at a skill directory with aSKILL.md. That makes the package simple, but it also means mistakes hide in filenames and paths more often than in code.

The Manifest Is The Product Boundary

A minimal but real manifest declares identity, UI, bundled agents, bundled skills, and the permission surface the page may use. Thename field becomes the install directory, CLI argument, app namespace, and route identity, so choose it once and keep it stable.

{
  "name": "helloworld",
  "version": "0.1.0",
  "displayName": "Hello World",
  "description": "A dashboard page that greets you, plus an agent whose agentSpawn hook prints Hello World.",
  "author": "ilkerulusoy",
  "tags": ["demo", "hello-world", "starter"],
  "defaultEnabled": false,
  "agents": ["agents/helloworld-agent.json"],
  "skills": ["skills/hello-world"],
  "ui": {
    "entry": "dist/index.mjs",
    "pages": [
      {
        "route": "/apps/helloworld",
        "label": "Hello World",
        "icon": "Sparkles",
        "group": "Apps"
      }
    ]
  },
  "permissions": {
    "api": ["/api/status"],
    "events": ["agent:status"],
    "storage": false,
    "cron": false,
    "network": false
  }
}
  • ui.entry is relative to the app root and points at the built file, not source.
  • icon is a lucide-react icon name that the host resolves.
  • crons can declare inline scheduled jobs when the app needs recurring work.
  • permissions.api is a prefix allowlist checked by the SDK before a request leaves the page.

That last point is important for debugging. If the page tries to call an undeclared path, the SDK rejects locally with an error you can catch. Do not spend the first ten minutes looking for a server-side 403 when the manifest does not allow the path in the first place.

The UI Contract: Externals Are Not Optional

The dashboard host provides React, ReactDOM, lucide-react, and the app SDK through an import map. Your bundle should import those names as bare specifiers. It should not include its own copy. A second React instance means a second hook dispatcher, and that is how ordinary useState calls turn into broken render paths.

// ui/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

const HOST_PROVIDED = [
  'react',
  'react-dom',
  'react/jsx-runtime',
  'lucide-react',
  '@kirocrew/app-sdk',
  '@kirocrew/app-sdk/ui',
]

export default defineConfig({
  plugins: [react()],
  build: {
    lib: { entry: 'src/App.tsx', formats: ['es'], fileName: () => 'index.mjs' },
    rollupOptions: { external: HOST_PROVIDED },
    cssCodeSplit: false,
    emptyOutDir: true,
    target: 'esnext',
    minify: 'esbuild',
  },
})

Verify the bundle after every build. The output should show bare imports, not relative chunks and not inlined React internals.

grep -o 'from *"[^"]*"' dist/index.mjs | sort -u
# from "@kirocrew/app-sdk"
# from "@kirocrew/app-sdk/ui"
# from "react"
# from "react/jsx-runtime"

The Runtime Rule

The host loads your ESM module and expects export defaultto be a React component. If the built file does not end with a default export, the app problem is in the build output, not in the dashboard host.

The SDK Surface I Trust

The names are easy to find. The signatures are not. I wasted time trying to reverse-engineer prop shapes from minified bundles. That failed because the components live in lazy-loaded chunks, and the mangled names I wanted were not in the main file. What worked was reading a working reference app and treating the official scaffold as executable documentation.

SDK hookUse it for
useAppApi()Permission-scoped HTTP client: get, post, put, delete
useAppEvents(event, cb)Real-time WebSocket event subscription
useAppInfo()App metadata such as name, version, and permissions
useTheme()Reactive theme details when semantic classes are not enough
useNavigate()Navigate to KiroCrew routes
useNotify()Toast notifications
useNavBadge()Returns setBadge(count) for the sidebar badge
useChatLauncher()Open chat with an optional agent and prefilled message

The component list I saw used successfully included Card, CardTitle, Badge, StatCard,EmptyState, and PageHeader. I only put those verified signatures on the initial render path. Anything else can wait behind a click handler or become plain markup until a working sample proves the prop shape.

Never call SDK surface whose shape you have not seen in code that runs. A wrong prop might not warn. It can throw during first render and blank the entire page.

Use Semantic Host Classes, Not Hard-Coded Colors

My first version used classes like bg-white/10 and divide-white/10. They looked acceptable in dark mode and wrong in light mode. KiroCrew already ships semantic Tailwind tokens that follow the user's theme. Use those and the page feels native without asking useTheme() for every choice.

ClassUse
bg-cardCard or raised surface
border-borderAny border
text-accentAccent-colored text
text-mutedSecondary text
table-stripedZebra table rows

This is the same design lesson we use when shipping client dashboards at Halmob: let the host own the theme. An app page should look like part of the product, not like a foreign iframe.

Permissions Are A Gate, Not A Sandbox

The app SDK enforces declared API and event permissions before requests and subscriptions leave the page. Server-side app tokens are also deny-by-default for ordinary gateway API access. That is useful. It is not the same thing as a process sandbox.

"permissions": {
  "api": ["/api/crons", "/api/status"],
  "events": ["notification", "slots"],
  "mcpTools": ["cron_add", "cron_list"],
  "storage": true,
  "cron": true,
  "network": false
}

Trust Boundary

App Python is loaded into the gateway process with the gateway's own privileges. The permission system gates the SDK tool surface; it does not stop Python imports, filesystem access, network calls, or subprocesses. Installing an app is equivalent to running that app's code with KiroCrew privileges. Only enable apps you trust.

Bundling An Agent With The App

An app's agent files are copied into the global agent directory with a namespace prefix. For example, agents/helloworld-agent.jsonbecomes ~/.kiro/agents/helloworld--helloworld-agent.json. That prefix prevents name collisions and ties the agent lifecycle back to the app.

{
  "name": "helloworld-agent",
  "model": "auto",
  "description": "Demo agent — an agentSpawn hook injects a greeting before the first turn",
  "prompt": "You are the Hello World demo agent...",
  "welcomeMessage": "Hello World 👻 — helloworld agent ready.",
  "tools": ["fs_read", "grep", "glob", "@kirocrew-core"],
  "allowedTools": ["fs_read", "grep", "glob"],
  "hooks": {
    "agentSpawn": [
      {
        "command": "printf 'Hello World\\nSession: %s\\n' \"${KIRO_SESSION_ID:-unknown}\"",
        "timeout_ms": 5000
      }
    ]
  }
}
  • Hook paths must be absolute or inline. A relative shell path only works when the session starts in the directory you expected.
  • agentSpawn stdout goes to context. The user sees the welcome message and the agent's first answer, not raw hook output.
  • tools and allowedTools are different. Tools exist; allowed tools run without confirmation. Keep write and execute tools out of the allowlist unless the app truly needs them.

The skill side is simpler: ship a directory with a SKILL.mdfile and YAML frontmatter. Write the description and triggers as retrieval text, not marketing copy, because the agent has to decide when the skill belongs in context. This matches the broader skill pattern we covered in our Hermes Skill.md guide.

The Install And Iteration Loop

The install path is straightforward. The first wall appears when you try to reinstall the same version after a UI change.

kirocrew app install /path/to/my-dashboard
kirocrew app enable my-dashboard
kirocrew app list
kirocrew app info my-dashboard

Reinstalling an already-installed app produces the obvious error: uninstall first or use the update endpoint. The better answer during UI work is dev mode.

kirocrew app dev helloworld
# UI files are served with no-store and edits under ui/ trigger live reload

Combine dev mode with the symlink the tool suggests. Keep the installed copy as a backup, symlink the app's ui directory to your source tree, and the whole loop becomes one command.

cd ~/.kiro/crew/apps/helloworld
mv ui ui.installed-backup
ln -s /path/to/source/kiro-helloworld/ui ui

cd /path/to/source/kiro-helloworld/ui
npm run build

Make Reloads Visible

Stamp the build time into the bundle with Vite's defineoption and render it in tiny muted text. When the browser timestamp matches the last build, you know the dev-mode chain is working without opening devtools.

Debugging The Blank Page

This is the failure mode that matters: the sidebar entry exists, the route loads, and the page is empty. No useful error reaches the user. The host loaded the module, then the component threw during its first render. The fix is not cleverness. It is disciplined reduction.

  • 1Check the export shape. The built file should contain one default export. If it does not, the app build is wrong before the component even runs.
  • 2Read the first browser console error. If you can get it, it usually names the throwing call directly.
  • 3Remove unverified SDK calls from first render. Replace unknown components with plain semantic Tailwind until the page returns.
  • 4Push risky calls behind click handlers. A bad signature inside a guarded click costs a logged error. A bad signature during render costs the whole page.
  • In my case, I removed SegmentedControl, useTheme, useNotify, and Btnin one pass. The page came back. That means I know the fix, but not the exact culprit. That is an acceptable production trade as long as the writeup stays honest: I eliminated a set of suspects; I did not prove a single root cause.

    The Gotchas Checklist

    • React, ReactDOM, react/jsx-runtime, lucide-react, and SDK packages are external.
    • The component is default-exported and the built ESM file exports it as default.
    • Only SDK props seen in working code run during first render.
    • Semantic classes such as bg-card, border-border, and text-muted replace hard-coded colors.
    • Every fetched path is in permissions.api, and every subscribed event is in permissions.events.
    • Live event arrays are bounded so a long-open tab does not grow forever.
    • Agent hook commands use absolute paths or inline commands.
    • Write and execute tools are excluded from allowedTools unless there is a deliberate reason.
    • Dev-mode symlinks are reverted before considering the install permanent.
    • No nested app.json exists inside the app tree; a sample manifest copied into the root can be read as a second app.

    The Smallest Real Page

    This is the pattern I would start from now: one status request, one bounded event feed, one badge counter, and only verified UI signatures.

    import { useAppApi, useAppEvents, useNavBadge } from '@kirocrew/app-sdk'
    import { Badge, Card, CardTitle, EmptyState, PageHeader, StatCard } from '@kirocrew/app-sdk/ui'
    import { useCallback, useEffect, useState } from 'react'
    
    const MAX_EVENTS = 20
    
    export default function MyApp() {
      const api = useAppApi()
      const setBadge = useNavBadge()
      const [status, setStatus] = useState<Record<string, unknown> | null>(null)
      const [error, setError] = useState<string | null>(null)
      const [events, setEvents] = useState<string[]>([])
    
      const load = useCallback(() => {
        setError(null)
        return api
          .get<Record<string, unknown>>('/api/status')
          .then(setStatus)
          .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
      }, [api])
    
      useEffect(() => { load() }, [load])
    
      useAppEvents('agent:status', (data: unknown) => {
        setEvents((prev) => [JSON.stringify(data), ...prev].slice(0, MAX_EVENTS))
      })
    
      useEffect(() => { setBadge(events.length) }, [events.length, setBadge])
    
      return (
        <>
          <PageHeader title="My App" subtitle="A KiroCrew app page" />
          <Card>
            <CardTitle>Gateway</CardTitle>
            {error ? <Badge variant="err">unreachable</Badge>
              : status ? <Badge variant="ok">online</Badge>
              : <Badge variant="warn">checking</Badge>}
          </Card>
          <Card>
            <CardTitle>Events</CardTitle>
            {events.length === 0
              ? <EmptyState icon="📡" title="No events yet" />
              : <ul>{events.map((e, i) => <li key={i}>{e}</li>)}</ul>}
          </Card>
        </>
      )
    }

    What I Would Do Differently

    I would get a working reference app before writing a single line of UI. Every hour I lost went to guessing SDK signatures, doing regex archaeology on minified bundles, and then recovering from a blank page caused by a wrong call. One sample app at the start would have prevented almost all of it.

    I would also turn on dev mode first, not last. Three uninstall, install, and enable cycles were enough to prove the CLI path worked, but not enough to make iteration pleasant. Dev mode plus a symlink made the loop feel like normal frontend development again.

    The larger lesson is to separate verified facts from assumptions while you build. Once I wrote down the unverified SDK surface, the blank page became a finite debugging problem instead of a mystery. That is also how we approach production agent work at Halmob: keep the contracts small, verify the boundary, and make unknowns visible before they become the outage.

    If you are building a KiroCrew app, start with the official scaffold, keep the host externals external, use only proven SDK signatures in the render path, and make your dev loop one build command. The rest is just product work.