API Reference

August 22, 2026 · View on GitHub

Reference for the KiroCrew Gateway HTTP and WebSocket APIs, and how apps consume them.

How you talk to the Gateway depends on where your code runs:

  • Dashboard UI pages (TypeScript/React) — use the @kirocrew/app-sdk hooks (useAppApi, useAppEvents, …). You do not npm install this package; the dashboard host provides it at runtime through its import map (the bare specifier @kirocrew/app-sdk resolves to the host's vendored copy via window.__kirocrew_modules). See getting-started.md and the App SDK Hooks section below.
  • Python apps / external CLI tools / services — use the standalone kirocrew-client package (pip install kirocrew-client). It is async (aiohttp) and has no dependency on the KiroCrew main package. See the Python Client section.
  • Node.js / Electron apps — call the Gateway REST/WS endpoints directly via fetch() / a WebSocket. The full endpoint list is in Gateway REST API Endpoints.

There is no published TypeScript gateway-client npm package. The kirocrew-client method names below describe the canonical Gateway API surface — the same endpoints any client (including raw fetch) talks to.

App SDK Hooks (dashboard UI)

Dashboard UI pages import permission-scoped hooks from @kirocrew/app-sdk, resolved at runtime via the host import map:

import { useAppApi, useAppEvents } from '@kirocrew/app-sdk'

function MyPage() {
  const api = useAppApi()        // permission-scoped GET/POST/PUT/PATCH/DELETE
  useAppEvents('notification', (e) => console.log(e))
  // ...
}

useAppApi() returns a client whose methods (get, post, put, patch, del) call the Gateway endpoints listed below, scoped to the permissions.api paths your app.json declares. The host injects auth automatically.

For the full hook list see getting-started.md.

Chat Marker Protocol

An agent encodes UI affordances inline in the prose it streams. A surface that renders a transcript has to interpret them, because the backend deliberately leaves the complete marker in the stream for a frontend consumer to extract:

MarkerMeaning
[OPTIONS: a | b]follow-up choices, several may be picked
[OPTION: a | b]follow-up choices, one only
[STEERING steer-<id>: …]the agent acknowledging a mid-turn steer

Two failure modes matter, and both are the consumer's responsibility. Render the text unparsed and the user reads machine syntax. Strip the marker without offering the choices and the user's options are deleted — worse than leaving them visible, because the text is gone too.

The parsers live in one React-free module so every surface reads the protocol from the same place:

website/src/app-sdk/protocol/
  optionMarker.ts   the marker pattern (in-tree only) + stripPartialOptionMarker
  options.ts        parseOptions, deriveFollowUpOptions
  steering.ts       extractSteeringAcks

Using it from an app

Apps resolve @kirocrew/app-sdk through the host import map, the same way they get the hooks:

import { parseOptions, extractSteeringAcks, deriveFollowUpOptions } from '@kirocrew/app-sdk'
import type { ChatMessage, ParsedOptions } from '@kirocrew/app-sdk'

function AgentTurn({ message }: { message: ChatMessage }) {
  // Strip the steer acknowledgement first, then the option marker: the text you render is
  // whatever is left, and the pieces you pulled out become your own affordances.
  const { cleaned, acks } = extractSteeringAcks(message.content ?? '')
  const { text, options, multi }: ParsedOptions = parseOptions(cleaned)

  return (
    <>
      <p>{text}</p>
      {acks.map(a => <SteeredChip key={a} summary={a} />)}
      {options.length > 0 && <MyChoiceButtons options={options} multi={multi} />}
    </>
  )
}

To decide whether choices still apply to the conversation rather than to one message, use deriveFollowUpOptions(messages, isStreaming). It walks back to the most recent real assistant turn and returns none while streaming, after a user reply, or after a queued send — so stale buttons do not linger:

const { followUpOptions } = deriveFollowUpOptions(messages, running)

The module imports no React and no dashboard component, so it is also usable from a worker, a test, or a non-React renderer.

Using it from a core dashboard page

A page inside website/src/ imports the same barrel by relative path — there is no second implementation and no dashboard-only variant:

import { parseOptions, stripPartialOptionMarker } from '../../app-sdk/protocol'

stripPartialOptionMarker exists for the streaming case: mid-stream the text can end with a half-arrived [OPTIONS: … that the full-marker regex cannot match yet, and showing it would let raw syntax type itself out in front of the user. Apply it to the parsed text while a turn is streaming.

The regex itself is not part of the app surface. It carries the global-flag lastIndex state, so handing it out lets an app's .test() call make this module's own scan start mid-string and miss the marker — the exact failure the module exists to prevent. Apps get functions; the pattern stays in-tree.

Exports

ExportKindPurpose
parseOptions(content)functionsplit prose from choices; returns ParsedOptions
deriveFollowUpOptions(messages, isStreaming)functionthe choices that still apply to the conversation
extractSteeringAcks(content)functionpull [STEERING …] out, returning { cleaned, acks }
stripPartialOptionMarker(text)functionhide a half-streamed marker
ParsedOptionstype{ text, options, multi, isPlan }
FollowUpDerivationtype{ followUpOptions, followUpIsPlan }
ChatMessagetypethe message shape deriveFollowUpOptions consumes

The module must stay free of React and of anything under pages/ or components/: a parser that lives in a component is only available to surfaces that render that component, which is what made a transcript print raw marker text. website/src/test/chatProtocolBoundary.test.ts asserts that, and also that no other non-test source defines the markers a second time.

Chat Transcript Rendering

ChatMessageList renders a transcript. Which component draws a given row is a registry keyed by the message's role, so you add a row type or replace one instead of forking the list.

import { ChatMessageList } from '@kirocrew/app-sdk'

<ChatMessageList messages={messages} running={running} />

That renders the built-in rows. To change one, pass renderers.

Adding a row the transcript does not draw

Four roles are deliberately undrawn — thinking, system, done and queued — because the dashboard shows them through other affordances. file is undrawn too. Claim one and it is yours:

const renderers = [{
  id: 'queued-card',
  roles: ['queued'],
  render: (m, ctx) => ctx.row(<div className="queued">{m.content}</div>),
}]

<ChatMessageList messages={messages} running={running} renderers={renderers} />

Limitation: two roles are grouped before your entry is consulted

thinking and permission (exported as GROUPED_ROLES, a frozen array) are assembled into one collapsible "worked through N steps" group before rows are resolved. An entry claiming either is still consulted, but it renders inside that group, and the group keeps its own summary and approval affordance — so you cannot yet use the registry to replace the built-in approval UI with your own. Substituting the group itself is not an extension point today — tracked in #2940.

Replacing a built-in row

Reuse the built-in's id:

const renderers = [{
  id: 'error',                       // replaces the built-in error row
  roles: ['error'],
  render: (m, ctx) => ctx.row(<MyErrorCard text={m.content} />),
}]

Import defaultMessageRenderers if you need to read what the built-ins do, and resolveRenderer / mergeRenderers if you are composing a registry yourself rather than handing one to ChatMessageList.

What a renderer is handed

FieldPurpose
index, messagesposition and the whole transcript, for a row that must look ahead
runningwhether the session is producing output
keythe row's stable React key
wrapper(children, isUser)bubble layout; isUser right-aligns
row(children, tight)full-width layout for cards, pills and banners
onFileOpenopen a path, when the host supports it
autoDeniedIdstool calls a policy or hook blocked
renderToolthe host's tool row, if it passed one

Two rules the registry relies on:

  • Shape beats role. Resolution is first-match, and your entries sit between the two built-ins recognised by message shape — a stop event and a sub-agent completion, which claim '*' and gate on a match predicate — and the role-keyed ones. This matters because a stop event reaches the transcript as role system, which is also a role you are invited to claim: were a role claim allowed to outrank a kind check, claiming system would swallow the stop card and pressing Stop would draw your row instead. A role claim cannot know about kind, so it does not outrank one. Replacing a shape-matched row is still possible and stays explicit — reuse its id.
  • Returning null is different from not claiming a role. An entry that exists and draws nothing says "no row by design"; no entry at all says "nothing handles this". Both look identical on screen, so website/src/test/messageRenderers.test.ts pins which is which.

Exports

ExportKindPurpose
ChatMessageListcomponentthe transcript
defaultMessageRenderersvaluethe built-in registry, in resolution order
mergeRenderers(extra)functionshape-matched defaults, then host entries, then the rest
resolveRenderer(message, renderers)functionfirst entry that claims the message
ToolCallPillcomponentthe store-free tool row the default registry uses
GROUPED_ROLESvaluefrozen array of the roles grouped before per-row resolution (see the limitation above)
MessageRenderertype{ id, roles, match?, render }
MessageRenderContexttypewhat render is handed

The registry takes no store and no router dependency, and reads live state only through the context it is handed — an app runs outside the dashboard's React root and has no store to select from. A row that genuinely needs live app state is supplied by the host as an entry.

Gateway API Surface

The sections below document the canonical Gateway API surface as exposed by the kirocrew-client Python package (see the Python Client section for the constructor and full method list). Method names are also a convenient way to refer to each endpoint — the same endpoints any client (including raw fetch) talks to.

When app_name is set and no explicit auth is provided, the client auto-reads the app secret from ~/.kiro/crew/apps/{name}/.app_secret and exchanges it for a short-lived token via POST /api/apps/{name}/token.

Authentication

MethodReturnsDescription
authenticate()Promise<boolean>Exchange app secret for token (auto-called if appName set)
setToken(token)voidManually set auth token on both HTTP and WS clients

Connection

MethodReturnsDescription
ping()Promise<boolean>Check if Gateway is reachable
getStatus()Promise<GatewayStatus>Gateway health (version, uptime, slots, provider)
getSystemInfo()Promise<SystemInfo>CPU, memory, disk metrics

Chat Slots

MethodReturnsDescription
createSlot(name, agent?)Promise<SlotInfo>Create a new chat session
listSlots()Promise<SlotInfo[]>List all active sessions
deleteSlot(slotId)Promise<void>Remove a session
getSlotHistory(slotId, limit?)Promise<{messages, total}>Get slot message history
sendMessage(slotId, message)Promise<void>Send a message (validates length, auto-flushes pending context)

WebSocket Events

MethodReturnsDescription
connect()voidOpen WebSocket connection
disconnect()voidClose WebSocket connection
connectedbooleanCurrent connection state
onChatChunk(slotId, cb)() => voidStream response chunks for a slot
onChatDone(slotId, cb)() => voidResponse complete for a slot
onNotification(cb)() => voidReceive notifications
onToolCall(cb)() => voidReceive tool call events
onConnectionChange(cb)() => voidConnection state changes
onRaw(cb)() => voidAll parsed WebSocket events
onRawMessage(cb)() => voidAll raw WebSocket messages

All on* methods return an unsubscribe function.

WebSocket event types: chat_chunk, chat_done, chat_message, chat_error, tool_call, notification, slots, slot_title, dashboard, log, refresh, approval, subagent_done, task_update, task_complete, proactive_notification, app_reload, error.

Subagents

MethodReturnsDescription
spawn(task, agent?)Promise<string>Spawn a background subagent
spawnMany(tasks, agents?)Promise<string[]>Spawn multiple subagents in parallel
listSubagents()Promise<SubagentInfo[]>List all subagents
getSubagentStatus(id)Promise<SubagentResult>Get subagent output

Cron Jobs

MethodReturnsDescription
addCron(name, options)Promise<CronJob>Create a scheduled job
listCrons()Promise<CronJob[]>List all cron jobs
updateCron(id, options)Promise<CronJob>Update a cron job
removeCron(id)Promise<void>Delete a cron job
pauseCron(id)Promise<void>Pause without deleting
resumeCron(id)Promise<void>Resume a paused job

Lessons

MethodReturnsDescription
addLesson(rule, category, scope?)Promise<void>Save a learned rule
listLessons()Promise<Lesson[]>List all lessons
removeLesson(query)Promise<void>Remove matching lessons

Notifications

MethodReturnsDescription
sendNotification(text, options?)Promise<void>Send via Slack or dashboard
listNotifications()Promise<{notifications}>List notifications
ackNotifications()Promise<void>Acknowledge all notifications

Approvals

MethodReturnsDescription
approveAction(slotId, taskId)Promise<void>Approve a pending tool action
rejectAction(slotId, taskId)Promise<void>Reject a pending tool action
resolveApproval(approvalId, approved)Promise<void>Resolve an approval by ID
getApprovalMode()Promise<'auto'|'interactive'>Get current approval mode
setApprovalMode(mode)Promise<void>Set approval mode

Models

MethodReturnsDescription
listModels()Promise<ModelInfo[]>List available LLM models
setSlotModel(slotId, model)Promise<void>Set model for a slot

MCP Servers

MethodReturnsDescription
listMcpServers()Promise<McpServerInfo[]>List registered MCP servers
registerMcpServer(def)Promise<void>Register an MCP server (requires name + command)
removeMcpServer(name)Promise<void>Remove an MCP server
registerAppMcp(name, entry)Promise<void>Write MCP entry to ~/.kiro/crew/mcp.json (Node.js only)
unregisterAppMcp(name)Promise<void>Remove MCP entry from ~/.kiro/crew/mcp.json (Node.js only)

Agent & Skill Installation (Node.js only)

MethodReturnsDescription
installAgentConfig(name, config)voidInstall agent JSON to ~/.kiro/agents/ (merges mcpServers)
removeAgentConfig(name)voidRemove agent config
installSkill(name, srcDir)voidCopy skill directory to ~/.kiro/crew/skills/
removeSkill(name)voidRemove skill directory

Agent Runtime

MethodReturnsDescription
dispatchAgent(agent, prompt)Promise<TaskResult>Run agent synchronously
dispatchAgentAsync(agent, prompt)Promise<string>Run agent in background
getTaskResult(taskId)Promise<TaskResult>Poll task status

Gateway Config

MethodReturnsDescription
getGatewayConfig(key)Promise<Record<string, unknown>>Read gateway config section
setGatewayConfig(key, value)Promise<void>Write gateway config section

App Storage

MethodReturnsDescription
getAppDataDir()stringApp-scoped data directory path
getAppConfig()Promise<Record<string, unknown>>Read app config via REST
setAppConfig(config)Promise<void>Write app config via REST

Memory

MethodReturnsDescription
memorySearch(query, topK?)Promise<MemoryResult[]>Semantic memory search

Context Injection

Silent background context for LLM — content appears in the next user-initiated turn without triggering a response or showing a visible message.

MethodReturnsDescription
injectContext(slotId, content, options?)Promise<void>Inject context (null slotId = buffer locally)
flushPendingContext(slotId)Promise<void>Flush buffered entries to a slot
setDefaultSlot(slotId)voidAuto-flush pending context on sendMessage
pendingContextCountnumberNumber of buffered context entries

Options: { source?: string, ephemeral?: boolean, maxAge?: number }

Constraints (400 on violation):

  • source: ≤64 chars, no control characters or newlines; whitespace-trimmed (a padded label and its bare form share one per-source cap bucket)
  • maxAge: must be a finite positive number (rejects boolean, NaN, Infinity, ≤0); omit or pass null for no expiry
  • content: must be a non-empty string, ≤40,000 chars

Ownership (404 on refusal; applies to app callers — a dashboard caller is unrestricted):

  • An app may only target a slot it owns, and a slot carrying no app scope is refused as well.
  • Owning the slot is not sufficient: an app is refused when the slot's session is linked elsewhere — a cron result or workflow injection holding that binding — because both writes land in the linked session, so slot ownership alone would otherwise reach a conversation the app has no claim on.
  • Every refusal returns the same body as a genuinely missing slot, so no response an unauthorized caller can reach distinguishes "not yours" from "does not exist". The specific reason is recorded in the security-event log instead.

Notes

POST /api/chat/slots/{slot}/note drops a short declarative line into a chat that is both visible in the transcript immediately and known to the agent on the user's next message — without firing an LLM turn. Context injection alone is silent; a transcript append alone is invisible to the model, because a live provider forwards only the new user message. The note endpoint does both writes against one slot.

Body: { content, source?, maxAge?, ephemeral? }. A note always does both writes -- there is no visible-only or context-only mode. The visible line is appended as role: "inject" with cls: "reconcile-note", and its content is redacted (credentials, exfiltration URLs) before it reaches the transcript. maxAge defaults to 24h for the context half when the key is omitted, so a note nobody follows up on expires instead of attaching to an unrelated message later. An explicit null means no expiry, the same as it does on /context — the two endpoints share the field and do not give it opposite meanings. The same source/maxAge/content constraints above apply.

Returns { ok, appended, visibleDeferred, deliveryConditional, contextSkipped, pending }. When the source's per-source context cap is full the request is not rejected: the visible line is still written and contextSkipped is true, because the cap protects the context queue rather than the transcript. If a turn is already running the note is held until that turn ends -- appended is false and visibleDeferred is true -- so that it lands on the next turn rather than the one it was written during. Ordering is preserved, and deliveryConditional is true whenever a note is held -- because a hold is delivered only if the slot still routes to the SAME session when the turn ends. An unbound slot can acquire a foreign binding while the note waits (a cron result or workflow injection claims an empty linked_session_key with no running gate), and both the transcript path and the next turn's session resolve that binding at flush time rather than at the POST. When that happens BOTH halves of the note are dropped rather than retargeted, because writing them would surface content authorized for one conversation inside another; the drop is recorded in the security-event log. So a 200 with visibleDeferred: true promises ordering against the running turn, not that the note will certainly be written. pending counts held entries as well as queued ones.

A 200 means "accepted for this gateway lifetime", not durable delivery. Both halves of a note live in memory only -- the held visible line and the queued context, the latter exactly as /context's queue has always behaved -- so a gateway restart between the acknowledgement and the next turn drops them. A caller that needs a note to survive a restart must re-post it; visibleDeferred: true promises ordering against the running turn, not persistence.

Proxy Authentication (Server-side)

Verify that an incoming request was signed by the KiroCrew gateway reverse proxy. Use in app backends to authenticate proxied requests.

FunctionReturnsDescription
verifyProxyRequest(req, appName, opts?)booleanVerify HMAC signature on any Node.js request object

Options: { secret?: string, maxAgeSecs?: number }


Python Client

Standalone async client using aiohttppip install kirocrew-client. Covers the full Gateway API surface documented above.

from kirocrew_client import KiroCrewClient

async with KiroCrewClient(app_name="my-app") as mc:
    ok = await mc.ping()
    slots = await mc.list_slots()

Constructor

KiroCrewClient(
    base_url="",              # default: http://localhost:{KIROCREW_PORT or 5476}
    token="",                 # optional for localhost
    app_name="",              # for app-scoped storage & auto-auth
    timeout=30,               # request timeout seconds
    max_retries=3,            # retry count
    retry_base_delay=1.0,     # base delay for backoff
    message_length_limit=40000,
    on_auth_expired=None,     # async callback returning new token
)

Method Reference

Method names use snake_case per Python convention. The left column is the canonical API-surface name used in the sections above:

API surfacePython
ping()ping()
getStatus()get_status()
getSystemInfo()get_system_info()
createSlot(name, agent?)create_slot(name, agent="")
listSlots()list_slots()
deleteSlot(id)delete_slot(id)
getSlotHistory(id, limit?)get_slot_history(id, limit=50)
sendMessage(id, msg)send_message(id, msg)
spawn(task, agent?)spawn(task, agent="")
spawnMany(tasks, agents?)spawn_many(tasks, agents=None)
listSubagents()list_subagents()
getSubagentStatus(id)get_subagent_status(id)
addCron(name, opts)add_cron(name, **opts)
listCrons()list_crons()
updateCron(id, opts)update_cron(id, **opts)
removeCron(id)remove_cron(id)
pauseCron(id)pause_cron(id)
resumeCron(id)resume_cron(id)
addLesson(rule, cat, scope?)add_lesson(rule, cat, scope="")
listLessons()list_lessons()
removeLesson(query)remove_lesson(query)
sendNotification(text, opts?)send_notification(text, **opts)
listNotifications()list_notifications()
ackNotifications()ack_notifications()
approveAction(slot, task)approve_action(slot, task)
rejectAction(slot, task)reject_action(slot, task)
resolveApproval(id, ok)resolve_approval(id, ok)
getApprovalMode()get_approval_mode()
setApprovalMode(mode)set_approval_mode(mode)
listModels()list_models()
setSlotModel(slot, model)set_slot_model(slot, model)
getGatewayConfig(key)get_gateway_config(key)
setGatewayConfig(key, val)set_gateway_config(key, val)
listMcpServers()list_mcp_servers()
registerMcpServer(def)register_mcp_server(name, cmd, args?, env?)
removeMcpServer(name)remove_mcp_server(name)
registerAppMcp(name, entry)register_app_mcp(name, *, url?, cmd?, ...)
unregisterAppMcp(name)unregister_app_mcp(name)
installAgentConfig(name, cfg)install_agent_config(name, cfg)
removeAgentConfig(name)remove_agent_config(name)
installSkill(name, dir)install_skill(name, dir)
removeSkill(name)remove_skill(name)
dispatchAgent(agent, prompt)dispatch_agent(agent, prompt)
dispatchAgentAsync(agent, prompt)dispatch_agent_async(agent, prompt)
getTaskResult(id)get_task_result(id)
getAppDataDir()get_app_data_dir()Path
getAppConfig()get_app_config()
setAppConfig(cfg)set_app_config(cfg)
memorySearch(q, topK?)memory_search(q, top_k=8)
injectContext(slot, content, opts?)inject_context(slot, content, *, source?, ephemeral?, max_age?)
flushPendingContext(slot)flush_pending_context(slot)
setDefaultSlot(slot)set_default_slot(slot)

Proxy Authentication (standalone functions):

API surfacePython
verifyProxyRequest(req, appName, opts?)verify_proxy_request(request, app_name, *, secret?, max_age_secs?)
verify_proxy_request_raw(header, method, path, app_name, ...)

AppManifest

Validate and serialize app.json manifests, via the kirocrew-client package.

from kirocrew_client import AppManifest

m = AppManifest.from_dict({"name": "my-app", "version": "1.0.0", ...})
errors = m.validate()   # list[str] — empty if valid
data = m.to_dict()

AppLifecycle

Manage app installation via the Gateway REST API.

from kirocrew_client import KiroCrewClient, AppLifecycle

async with KiroCrewClient() as mc:
    lifecycle = AppLifecycle(mc)
    await lifecycle.install("/path/to/my-app")
    await lifecycle.enable("my-app")
    await lifecycle.disable("my-app")
    await lifecycle.uninstall("my-app")
    apps = await lifecycle.list()

GatewayManager

Manage the KiroCrew Gateway process (start, stop, health check).

from kirocrew_client import GatewayManager

gm = GatewayManager(port=5476)
await gm.start()
healthy = await gm.is_healthy()
await gm.stop()

Error Handling

All kirocrew-client errors are KiroCrewError instances with code, message, status, body.

CodeTriggerRetried?
AUTH_REQUIREDRemote connection without tokenNo
AUTH_EXPIRED401/403 responseNo (calls on_auth_expired if set)
VALIDATION_ERRORInvalid inputNo
NOT_FOUND404 responseNo
RATE_LIMITED429 responseYes (Retry-After or backoff)
SERVER_ERROR5xx responseYes (exponential backoff)
NETWORK_ERRORTimeout or connection failureYes (exponential backoff)
WS_DISCONNECTEDWebSocket not connectedNo
from kirocrew_client import KiroCrewError

try:
    await mc.send_message("slot-1", "hello")
except KiroCrewError as e:
    print(e.code, e.message, e.status)

Gateway REST API Endpoints

The useAppApi() hook and the kirocrew-client package wrap these Gateway endpoints. Apps can also call them directly via fetch().

App Management

MethodPathDescription
GET/api/appsList all installed apps
GET/api/apps/registryList available apps from registry
GET/api/apps/blob?repo=&path=&ref=Proxy images from a registry app's git repo
POST/api/apps/installInstall from local path
POST/api/apps/registerRegister a self-managed app
POST/api/apps/registry/installInstall from registry
GET/api/apps/{name}Get app details
GET/api/apps/{name}/manifestGet app manifest
GET/PUT/api/apps/{name}/configRead/write app config
POST/api/apps/{name}/updateUpdate installed app
POST/api/apps/{name}/uninstallUninstall app
POST/api/apps/{name}/enableEnable app
POST/api/apps/{name}/disableDisable app
POST/api/apps/{name}/devToggle dev mode (live reload) — body {"enabled": bool}
POST/api/apps/{name}/openLaunch app via openCommand
GET/apps/{name}/ui/{path}Serve app UI bundle files
*/apps/{name}/api/{path}Reverse proxy to app backend (HMAC-signed)

Reverse Proxy Authentication

The gateway signs each proxied request with X-KiroCrew-Proxy: <timestamp>:<hmac-sha256>. The HMAC is computed over the message timestamp:method:/api/path[?query]:sha256(body) using the app secret as the key, where sha256(body) is the hex SHA-256 digest of the raw request body (an empty body hashes the empty byte string, e3b0c442...). Binding the body hash means a tampered body invalidates the signature. Backends verify with a constant-time comparison and reject requests whose timestamp is not within ±60s of now.

Python app backends verify this with kirocrew-client:

from kirocrew_client import verify_proxy_request
if not verify_proxy_request(request, 'my-app'): return Response(status=401)

Node.js app backends can verify the signature directly: compute HMAC-SHA256(timestamp:method:/api/path[?query]:sha256(body), app_secret) and compare against the value in the X-KiroCrew-Proxy header (constant-time), rejecting stale timestamps.

Breaking change (body-bound signature): verify_proxy_request / verify_proxy_request_raw in the kirocrew-client package MUST be regenerated in lockstep to bind sha256(body) while keeping the constant-time compare and ±60s freshness. A gateway that signs body-bound HMACs will fail verification against any deployed old verifier, so the client release must ship together with this change.

App Dev Mode (live reload)

Dev mode speeds up app-UI iteration: no manual copy-and-hard-refresh loop. When an installed app is in dev mode the gateway serves its UI files with Cache-Control: no-store and watches the app's ui/ directory; on any file change it broadcasts an app_reload WebSocket event and the dashboard reloads the app so edits appear immediately. The recommended setup symlinks ~/.kiro/crew/apps/<name>/ui/ to your source tree so the watcher sees edits at the real files.

Contract surface:

  • installed.json field — dev: bool (default false): persisted per-app flag. Tolerant on read (absent ⇒ false); reversible; no migration needed. Builtin apps cannot enter dev mode.
  • Endpoint — POST /api/apps/{name}/dev, body {"enabled": <bool>}. Returns {"name": <name>, "dev": <bool>}. 400 for a non-boolean body, a builtin app, or an unsafe app name; 404 if the app is not installed. Behind the standard gateway auth; emits an app_dev_mode SEL audit event.
  • WebSocket event — app_reload, payload {"app": <name>, "ts": <float>}. Re-dispatched to the frontend as the mc:app-reload window CustomEvent; the AppHost triggers a full page reload for the matching app.
  • CLI — kirocrew app dev <name> [--off]: toggles the flag out-of-process; the gateway watcher picks up the change within one poll interval, so no gateway restart is needed.

Cost model: dev mode is off for essentially all gateways. The authoritative per-app state is the installed.json dev field above; to keep the steady-state cost negligible the gateway also maintains an internal, unstable cache (a small sentinel file under ~/.kiro/crew/apps/, plus an in-memory mirror) listing the app names currently in dev mode. The watcher stat()s only that one file each second and walks a ui/ tree solely for apps in the set — so a gateway with no dev apps pays one stat() per second and never invokes the heavier list_apps() walk; the in-memory mirror lets the UI-serving hot path decide the cache header with no per-request disk IO. This sentinel is a derived cache and not part of the App Kit contract: its path, name, and format are internal implementation details, may change without notice, and must not be read or written by app or third-party tooling — treat installed.json dev as the only supported source of truth.