Reference for the Kiro Crew 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-sdkhooks (useAppApi,useAppEvents, …). You do notnpm installthis package; the dashboard host provides it at runtime through its import map (the bare specifier@kirocrew/app-sdkresolves to the host's vendored copy viawindow.__kirocrew_modules). See getting-started.md and the App SDK Hooks section below. - Python apps / external CLI tools / services — use the standalone
kirocrew-clientpackage, carried in this repository underpackages/kirocrew-client-py/. It is async (aiohttp) and has no dependency on the Kiro Crew main package, but it is not published to PyPI — use it from a source checkout. See the Python Client section. - Node.js / Electron apps — call the Gateway REST/WS endpoints directly via
fetch()/ a WebSocket. Selected endpoint paths are in Gateway REST API Endpoints.
There is no published TypeScript gateway-client npm package, and none is planned
here — the camelCase names used throughout the sections below are labels for
Gateway endpoints, not callable methods. Read them as endpoint identifiers.
The @kirocrew/app-sdk hooks are real and callable — see the next section; they
resolve from the host import map. The kirocrew-client Python package is not
published: it lives in this repository under packages/kirocrew-client-py/, is
outside the installed distribution, and has no release on PyPI, so pip install kirocrew-client does not work. Use it from a source checkout, or call the
endpoints directly with fetch or aiohttp. Its method list is in
Python Client.
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 (raw, request, get, post,
put, patch, del) call the Gateway endpoints listed below, scoped to the
permissions.api paths your app.json declares. JSON methods parse a JSON
response; an empty successful response returns undefined.
-
raw(path, init?)returns a successfulResponsewithout consuming its body. Use it for binary downloads, text or streamed responses and response headers. Non-success responses still throwAppApiError. Supply anAbortSignalfor long-lived streams and abort or cancel the reader when the component unmounts; the method does not implement EventSource reconnect or SSE parsing. -
request<T>(path, init?)acceptsRequestInit, including raw bodies such asFormData, headers and an abort signal. It does not set a content type for you. -
get<T>(path, init?)anddel<T>(path, init?)fix the HTTP method. -
post<T>(path, body?, init?),put<T>(path, body?, init?)andpatch<T>(path, body?, init?)serialize the body argument as JSON. Their method and body arguments take precedence overinit.methodandinit.body. Headers are merged with a defaultContent-Type: application/jsonunless you specify another media type.
The host owns X-Session-Key: chat surfaces use their bound session and routed
app pages use the core dashboard-page identity, dashboard:ui. A host-provided
key overrides a caller-supplied one. If a host has no binding, supplying that
header is rejected before a request is sent; callers of this scoped client
cannot choose a session. This is a frontend guardrail, not isolation from other
JavaScript in the dashboard document; backend authorization remains authoritative.
The path check applies to the initial URL. Browser redirect behavior remains
controlled by RequestInit.redirect (default follow); use redirect: 'error'
when the call must not follow redirects. Redirect targets are not rechecked by
this client.
HTTP failures remain Error objects with the message API <status>: <body> and
now also carry name: 'AppApiError', numeric status and string body. Import
AppApiError as a type, not a runtime constructor. The body is unparsed, so
parse it only when the endpoint promises JSON (for example, a conflict response).
Network, abort and successful-response JSON parsing failures retain their original
error types. Stale-owner reauthentication signaling still runs before an HTTP
failure is thrown.
The path matcher uses the backend's declared-pattern semantics: /api/example
matches itself and slash-delimited children; /api/example/* also includes the
base path; /api/example* includes any string prefix match. Blank entries match
nothing, surrounding whitespace is stripped, and request paths are normalized
before matching. A bare trailing slash is literal, not shorthand for /*.
An explicit authentication-expiry response (403, X-Auth-Required: true)
notifies the dashboard's existing recovery handler. It does not turn ordinary
permission denials into refresh attempts or automatically replay writes.
For the full hook list see getting-started.md.
ChatEmbed mounts Kiro Crew's native transcript and compact composer for an
existing session. The required slotKey selects the session. Existing props such
as agent, placeholder, frameless, startAtBottom, onSend, and
aboveComposer keep their current contracts.
import { ChatEmbed } from '@kirocrew/app-sdk'
<ChatEmbed slotKey="coder-abc123" />The composer accepts multiple lines. Enter sends the draft, Shift+Enter
inserts a line break, and an Enter used to commit an input method editor (IME)
candidate does not send. The box grows with the draft up to 240 pixels, then
keeps its height and scrolls vertically.
A host that boxes the embed at a fixed height passes composerMaxHeight (in
pixels) to lower that cap, so a long draft cannot take most of the box from the
transcript. The resting (empty) size of the composer is unchanged; only the cap
moves. Omitted, the 240-pixel default applies.
<div style={{ height: 420 }}>
<ChatEmbed slotKey="coder-abc123" composerMaxHeight={160} />
</div>ChatPanel mounts Kiro Crew's native chat experience for an existing session. The required
slotKey selects the session. By default, the component keeps the standard embedded ChatPage
behavior.
import { ChatPanel } from '@kirocrew/app-sdk'
<ChatPanel slotKey="coder-abc123" />Set conversationOnly when the host app already provides navigation and needs the conversation
without ChatPage's sessions rail. This mode keeps the native transcript, composer, and composer
controls, and it leaves the host page in charge of the browser URL.
<ChatPanel slotKey="coder-abc123" conversationOnly />| Prop | Type | Required | Purpose |
|---|---|---|---|
slotKey |
string |
yes | Select the Kiro Crew session rendered by the panel |
conversationOnly |
boolean |
no | Hide ChatPage's sessions rail and disable ChatPage URL synchronization |
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:
| Marker | Meaning |
|---|---|
[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
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.
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.
| Export | Kind | Purpose |
|---|---|---|
parseOptions(content) |
function | split prose from choices; returns ParsedOptions |
deriveFollowUpOptions(messages, isStreaming) |
function | the choices that still apply to the conversation |
extractSteeringAcks(content) |
function | pull [STEERING …] out, returning { cleaned, acks } |
stripPartialOptionMarker(text) |
function | hide a half-streamed marker |
ParsedOptions |
type | { text, options, multi, isPlan } |
FollowUpDerivation |
type | { followUpOptions, followUpIsPlan } |
ChatMessage |
type | the 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.
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.
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} />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.
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.
| Field | Purpose |
|---|---|
index, messages |
position and the whole transcript, for a row that must look ahead |
running |
whether the session is producing output |
key |
the row's stable React key |
wrapper(children, isUser) |
bubble layout; isUser right-aligns |
row(children, tight) |
full-width layout for cards, pills and banners |
onFileOpen |
open a path, when the host supports it |
autoDeniedIds |
tool calls a policy or hook blocked |
renderTool |
the 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 amatchpredicate — and the role-keyed ones. This matters because a stop event reaches the transcript as rolesystem, which is also a role you are invited to claim: were a role claim allowed to outrank akindcheck, claimingsystemwould swallow the stop card and pressing Stop would draw your row instead. A role claim cannot know aboutkind, so it does not outrank one. Replacing a shape-matched row is still possible and stays explicit — reuse itsid. - Returning
nullis 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, sowebsite/src/test/messageRenderers.test.tspins which is which.
| Export | Kind | Purpose |
|---|---|---|
ChatMessageList |
component | the transcript |
defaultMessageRenderers |
value | the built-in registry, in resolution order |
mergeRenderers(extra) |
function | shape-matched defaults, then host entries, then the rest |
resolveRenderer(message, renderers) |
function | first entry that claims the message |
ToolCallPill |
component | the store-free tool row the default registry uses |
GROUPED_ROLES |
value | frozen array of the roles grouped before per-row resolution (see the limitation above) |
MessageRenderer |
type | { id, roles, match?, render } |
MessageRenderContext |
type | what 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.
The sections below name the Gateway API surface. A name here is an endpoint
label, not a guarantee that a client method exists for it: the source-only
kirocrew-client Python package covers part of this surface, and
Python Client marks which part. For anything it does not
implement, call the endpoint directly — the paths are in
Gateway REST API Endpoints.
The Returns column describes the response shape. It is not a TypeScript type:
no TypeScript client ships, so SlotInfo, GatewayStatus, SystemInfo and
their siblings are response-shape names rather than importable types.
When app_name is set and no explicit auth is provided, the Python client reads
the app secret from ~/.kiro/crew/apps/{name}/.app_secret. For a remote Gateway,
call await client.authenticate() before the first request; the context manager
does not exchange the secret automatically. The same exchange refreshes a token
after a 401/403 response.
| Method | Returns | Description |
|---|---|---|
authenticate() |
boolean |
Exchange the app secret for a token; call explicitly before the first remote request |
setToken(token) |
void |
Conceptual token assignment; the Python client accepts token= in its constructor |
| Method | Returns | Description |
|---|---|---|
ping() |
boolean |
Check if Gateway is reachable |
getStatus() |
GatewayStatus |
Gateway health (version, uptime, slots, provider) |
getSystemInfo() |
SystemInfo |
CPU, memory, disk metrics |
| Method | Returns | Description |
|---|---|---|
createSlot(name, agent?) |
SlotInfo |
Create a new chat session |
listSlots() |
SlotInfo[] |
List all active sessions |
deleteSlot(slotId) |
— (no body) |
Remove a session |
getSlotHistory(slotId, limit?) |
{messages, total} |
Get slot message history |
sendMessage(slotId, message) |
— (no body) |
Send a message (validates length, auto-flushes pending context) |
| Method | Returns | Description |
|---|---|---|
connect() |
void |
Open WebSocket connection |
disconnect() |
void |
Close WebSocket connection |
connected |
boolean |
Current connection state |
onChatChunk(slotId, cb) |
() => void |
Stream response chunks for a slot |
onChatDone(slotId, cb) |
() => void |
Response complete for a slot |
onNotification(cb) |
() => void |
Receive notifications |
onToolCall(cb) |
() => void |
Receive tool call events |
onConnectionChange(cb) |
() => void |
Connection state changes |
onRaw(cb) |
() => void |
All parsed WebSocket events |
onRawMessage(cb) |
() => void |
All 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.
| Method | Returns | Description |
|---|---|---|
spawn(task, agent?) |
string |
Spawn a background subagent |
spawnMany(tasks, agents?) |
string[] |
Spawn multiple subagents in parallel |
listSubagents() |
SubagentInfo[] |
List all subagents |
getSubagentStatus(id) |
SubagentResult |
Get subagent output |
| Method | Returns | Description |
|---|---|---|
addCron(name, options) |
CronJob |
Create a scheduled job |
listCrons() |
CronJob[] |
List all cron jobs |
updateCron(id, options) |
CronJob |
Update a cron job |
removeCron(id) |
— (no body) |
Delete a cron job |
pauseCron(id) |
— (no body) |
Pause without deleting |
resumeCron(id) |
— (no body) |
Resume a paused job |
Provisional surface.
kiro_crew.irqhas exactly one probe today (pr_watch). The ~15 sibling pollers this abstraction was derived from cannot migrate onto it yet, so a second real consumer has not yet tested the contract. Treat the shapes below as subject to change until one has: build on them, but expectObservation/Tickto gain fields, and pin the Kiro Crew version your app was tested against.
An app that needs to keep an eye on an external thing — a deploy, a ticket, a queue depth — should not schedule an agent cron to go look. That spends a full model turn per check, and on a quiet subject every one of those turns says "nothing changed".
Schedule a script cron instead and build it on kiro_crew.irq, the
interrupt controller. The script runs in a subprocess with no model call at
all; a quiet tick is free. Only an unexpected observation raises a wake, and the
wake is delivered into the session that armed the cron as a real agent turn.
Full design: docs/system-specs/modules/agent-interrupt-controller.md.
You write the two things that are your domain knowledge — what to poll, and what counts as an anomaly — and the module owns masking (so one condition wakes once), coalescing (so several anomalies arrive as one wake), epoch resets (so a re-triggered subject forgets stale alerts), atomic per-watch state, and a consecutive-error backstop (so a broken probe says so instead of skipping quietly forever). Those are the four things a hand-rolled poller gets wrong, and each failure looks like success.
import json
from kiro_crew.irq import Observation, Probe, Severity, Tick, run
class DeployProbe(Probe):
def identity(self, ctx):
"""Return (subject_kind, subject_id); raise ValueError to self-remove."""
self.env = (json.loads(ctx.message or "{}") or {}).get("env") or ""
if not self.env:
raise ValueError('needs {"env": "..."}')
return ("deploy", self.env)
def observe(self, ctx):
"""One bounded call per tick. Never raise Skip/Report/Done."""
status = read_deploy_status(self.env)
if status is None:
return Tick(fetch_ok=False) # the kernel owns the backstop
if status.finished:
return Tick(epoch=status.id, observations=[
Observation("done", Severity.TERMINAL, f"{self.env} deployed."),
])
obs = []
if status.rolled_back:
# Nothing improves by waiting -> IMMEDIATE bypasses coalescing.
obs.append(Observation("rollback", Severity.IMMEDIATE,
f"{self.env} rolled back."))
for stage in status.failed_stages:
obs.append(Observation(f"stage:{stage}", Severity.WAKE,
f"{self.env}: stage {stage} failed."))
return Tick(epoch=status.id, observations=obs,
pending=status.running_stages)
def watch(ctx): # cron entry point
run(ctx, DeployProbe())Register it with addCron(name, { script: "<crons dir>/your_probe.py:watch", every: 300, timeout: 120, message: JSON.stringify({ env: "prod" }) }). Cron
scripts must live under the config directory's crons/, and the cron must be
armed from the session that should receive the wake — the cron system
captures the calling session at creation time.
Rules:
- Never raise
Skip/Report/Done. Return data; the kernel decides. It is the only place a verdict is raised. - A failed observation returns
Tick(fetch_ok=False), never an emptyTick— an empty tick reads as "nothing is wrong". - Use
Severity.IMMEDIATEonly for what genuinely cannot improve by waiting. Using it to mean "important" defeats coalescing. - Supply an
epochwhen the subject has an identity token. Without one there are no resets, so a re-triggered subject inherits the previous run's masks. - Filter out conditions the operator already knows about (a check red on the
base branch, a known-degraded dependency) in your own
observe()— do not return them. An earlier revision carried anexpected=Trueflag for this; it was removed because nothing read the state it recorded. - Keep
observe()to one bounded call. This half must stay fast and cheap. coalesce_secs=0turns coalescing off — pass it torun(), or return it from your probe'stuning()when it should come from the cron message. Do that when you would rather be woken early than woken once: coalescing costs at least one cron interval of latency, because a window cannot open and fire within the same tick.
Before your app sends content anywhere off the machine — an external document
store, a ticket, a wiki — run it through ctx.scrub. It applies the same
credential and exfiltration-URL redaction the gateway applies on its own
boundaries, by reference rather than by copy, so a pattern tightened in a later
release reaches your app with the wheel.
result = ctx.scrub.outbound(body) # may raise; see below
if result.redacted:
ctx.logger.info("scrub removed %d credential(s), %d url(s)",
result.credentials_removed, result.urls_removed)
publish(result.text) # only the scrubbed text may leaveoutbound(text) -> ScrubResult carries text, credentials_removed,
urls_removed and redacted.
You get counts, not descriptions, and there is deliberately no way to learn which value was removed. That is not an omission to be filled in later: the gateway's internal exfiltration warning includes the offending domain and the start of the query string, so handing those through would move a secret out of your published text and into your logs. Report the fact — "we removed something before sending" — rather than rewriting the user's content silently.
Use redacted rather than comparing against the original. It can be True with
both counts at zero: on a host running an edition companion, extra patterns apply
that the base counts do not include. It is never False when something was
removed.
outbound can raise, and you must not swallow it. On a host whose companion
fails to compose, it propagates rather than quietly falling back to weaker
redaction. Abandon the publish when that happens — publishing unredacted is worse
than not publishing.
outbound is the only method, on purpose: neither single pass is exposed alone,
because an app that wants half a redaction wants something this seam should not
make easy.
ctx.scrub needs no permission and is always present: it only removes data, so
there is nothing to withhold and no None branch that could become a silent
no-redaction path. Do not copy these patterns into your app — a set that drifts
from the gateway's is a control that looks present and is not.
When your app acts on the user's behalf against something outside the machine, record the decision in the same append-only security event log the gateway's own decisions land in — otherwise "who changed what, and what was refused" is answerable for the gateway and unanswerable for your half of the same operation.
ctx.audit.record("publish", "success", resources=doc_id)
ctx.audit.record("publish", "denied", resources=doc_id, error="no edit access")record(operation, outcome, *, resources="", error="") never raises — an audit
sink that is unwritable must not fail the user's publish.
outcome is a short verb you choose (success, denied, error, completed, …).
It is not checked against a vocabulary — a spelling of your own is kept, because
rewriting it would record something other than what happened. It is redacted and
length-clipped like resources and error, so a credential that reaches it by
accident is not written; that is a no-op for any real outcome value. This log is
append-only and readable by the dashboard OWNER over /api/sel/events — that
endpoint is owner-gated, so no non-owner dashboard user reads it. In-process app
code is NOT isolated from it, though: as the next paragraph says, hook code runs
inside the gateway and can reach the log directly, so treat anything you write
here as readable by a co-resident app. Nothing put in it can be taken back:
don't route free-form remote output through these fields.
There is no caller= argument. Attribution is minted from your app name
(app:<name>, the same tag ctx.cron uses for ownership), so there is no
parameter to pass the wrong value into. It is cooperative, not unforgeable: hook
code runs inside the gateway process and can construct another app's SDK or reach
the log directly, so treat app:<name> as "which app said this", not as proof.
operation is namespaced the same way, so two apps cannot collide on a bare
"publish". No permission gates it: an app cannot obtain anything with it, only
state what it did.
The gateway's own aiohttp Application, for background work that must be anchored
on it — a poller that has to read the same dashboard state your request handlers
read, and stash its running service where those handlers look it up.
async def on_startup(ctx):
if ctx.http_app is None:
ctx.health.mark_degraded("poller not started: no gateway application on this host")
return
await start_my_poller(ctx.http_app)
async def on_shutdown(ctx):
if ctx.http_app is None:
return
await stop_my_poller(ctx.http_app)Present only if your manifest declares a routes hook, and None otherwise.
That gate is not a permission you can ask for: an app with routes is dispatched the
real web.Request, so request.app is already this exact object and the field adds
no reach. An app with lifecycle hooks and no routes has no request path either, so
handing it the Application would be a genuinely new grant.
Read it with getattr(ctx, "http_app", None) if your app must also run on a gateway
older than this field, and report the gap — ctx.health.mark_degraded with the
user-visible consequence — rather than returning quietly. Background work that
silently never starts is indistinguishable from having nothing to do.
Your on_startup and on_shutdown contexts are built by different code paths and
are guaranteed to agree about this field, so work you start with it can always be
stopped with it. That holds across a version bump too: teardown reuses the answer
recorded when the app was enabled, so dropping your routes hook in a later release
does not strand the work an earlier one started — your on_shutdown still receives
the Application it was given. The same rule runs the other way, so adding a routes
hook does not hand the object to a teardown whose startup never held it. Declare
on_shutdown whenever you declare on_startup: anything you spawn outlives the
startup call, and teardown is the only thing that stops it.
| Method | Returns | Description |
|---|---|---|
addLesson(rule, category, scope?) |
— (no body) |
Save a learned rule |
listLessons() |
Lesson[] |
List all lessons |
removeLesson(query) |
— (no body) |
Remove matching lessons |
| Method | Returns | Description |
|---|---|---|
sendNotification(text, options?) |
— (no body) |
Send via Slack or dashboard |
listNotifications() |
{notifications} |
List notifications |
ackNotifications() |
— (no body) |
Acknowledge all notifications |
| Method | Returns | Description |
|---|---|---|
approveAction(slotId, taskId) |
— (no body) |
Approve a pending tool action |
rejectAction(slotId, taskId) |
— (no body) |
Reject a pending tool action |
resolveApproval(approvalId, approved) |
— (no body) |
Resolve an approval by ID |
getApprovalMode() |
'auto' | 'interactive' |
Get current approval mode |
setApprovalMode(mode) |
— (no body) |
Set approval mode |
| Method | Returns | Description |
|---|---|---|
listModels() |
ModelInfo[] |
List available LLM models |
setSlotModel(slotId, model) |
— (no body) |
Set model for a slot |
| Method | Returns | Description |
|---|---|---|
listMcpServers() |
McpServerInfo[] |
List registered MCP servers |
registerMcpServer(def) |
— (no body) |
Register an MCP server (requires name + command) |
removeMcpServer(name) |
— (no body) |
Remove an MCP server |
registerAppMcp(name, entry) |
— (no body) |
Write MCP entry to ~/.kiro/crew/mcp.json (Node.js only) |
unregisterAppMcp(name) |
— (no body) |
Remove MCP entry from ~/.kiro/crew/mcp.json (Node.js only) |
| Method | Returns | Description |
|---|---|---|
installAgentConfig(name, config) |
void |
Install agent JSON to ~/.kiro/agents/ (merges mcpServers) |
removeAgentConfig(name) |
void |
Remove agent config |
installSkill(name, srcDir) |
void |
Copy skill directory to ~/.kiro/crew/skills/ |
removeSkill(name) |
void |
Remove skill directory |
| Method | Returns | Description |
|---|---|---|
dispatchAgent(agent, prompt) |
TaskResult |
Run agent synchronously |
dispatchAgentAsync(agent, prompt) |
string |
Run agent in background |
getTaskResult(taskId) |
TaskResult |
Poll task status |
| Method | Returns | Description |
|---|---|---|
getGatewayConfig(key) |
a JSON object | Read gateway config section |
setGatewayConfig(key, value) |
— (no body) |
Write gateway config section |
| Method | Returns | Description |
|---|---|---|
getAppDataDir() |
string |
App-scoped data directory path |
getAppConfig() |
a JSON object | Read app config via REST |
setAppConfig(config) |
— (no body) |
Write app config via REST |
| Method | Returns | Description |
|---|---|---|
memorySearch(query, topK?) |
MemoryResult[] |
Semantic memory search |
Silent background context for LLM — content appears in the next user-initiated turn without triggering a response or showing a visible message.
| Method | Returns | Description |
|---|---|---|
injectContext(slotId, content, options?) |
— (no body) |
Inject context (null slotId = buffer locally) |
flushPendingContext(slotId) |
— (no body) |
Flush buffered entries to a slot |
setDefaultSlot(slotId) |
void |
Auto-flush pending context on sendMessage |
pendingContextCount |
number |
Number 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 expirycontent: 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.
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 for a held note is a durable acknowledgement — for a slot that has a durable identity. The hold is persisted verbatim (both halves, the silent context included) into the slot's own session metadata before the 200 is returned, replayed into the hold by both slot-restore paths after a gateway restart, and retired by the save that commits the delivered rows -- so a note accepted with visibleDeferred: true survives a restart and is delivered, unaltered, on the first turn after it. Two edges keep the original gateway-lifetime meaning instead: a memory-only deployment (no conversation log at all), and a slot that has never been persisted (no metadata line to attach the hold to -- such a tab does not itself survive a restart, so there is no restored slot the note could outlive). Do not re-post a held note after a restart; the restored hold delivers it, and a re-post would put the same line in the transcript twice. Three boundary refusals protect that promise: a note posted during a running turn is capped at 4,000 characters (413, code deferred_note_too_large -- shorten it or wait for the turn to end), a slot whose durable hold is full answers 429 deferred_notes_full until its rows are saved, and a slot that is rebound to another session while the hold is persisting answers the endpoint's uniform 404 -- the note was neither delivered nor made durable (a note the turn-end flush drops at that same rebind seam takes this 404 too; the 200 stands only when the note observably exists in a delivered row or the durable hold). The one retry-the-same-request signal is a 503 with code deferred_note_persist_failed, which means the durable write itself failed and the note was not accepted. The queued context of an immediate (non-held) note still behaves exactly as /context's queue always has -- in memory, for this gateway lifetime. Note the retention consequence of durability: a HELD note's context half -- the trusted-caller channel, which is deliberately not redacted -- now lives on disk in the session metadata until delivery or retirement, where an immediate note's context only ever lived in memory.
Verify that an incoming request was signed by the Kiro Crew Gateway reverse proxy. Use these main-package helpers in Python app backends:
| Function | Returns | Description |
|---|---|---|
raw_request_target(request) |
str |
Preserve the raw percent-encoded path and query that the Gateway signed |
proxy_secret() |
str |
Read the injected KIROCREW_PROXY_SECRET, or an empty string |
verify_proxy_request(header, *, method, target, body, secret=None, now=None) |
bool |
Verify the body-bound HMAC and fixed ±60-second freshness window; fail closed on malformed input |
Standalone async client using aiohttp, carried in this repository under
packages/kirocrew-client-py/. It is not published to PyPI or included in the
main wheel. Install it from a source checkout; it covers part of the Gateway API
surface documented above.
python -m pip install -e /path/to/KiroCrew/packages/kirocrew-client-pyfrom kirocrew_client import KiroCrewClient
async with KiroCrewClient(app_name="my-app") as mc:
ok = await mc.ping()
slots = await mc.list_slots()KiroCrewClient(
base_url="", # default: http://localhost:{KIROCREW_PORT or 5476}
token="", # optional for localhost
app_name="", # app-scoped storage and secret lookup
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
)For a remote Gateway, pass token=... or call await client.authenticate()
after entering the context. Local loopback requests need no token. Setting
app_name alone only locates the app secret; it does not authenticate during
__aenter__.
The left column is the endpoint label used in the sections above; the right
column is the shipped Python method, in snake_case per Python convention.
Rows marked not implemented are Gateway endpoints the shipped Python client
does not wrap yet. Call those endpoints directly with aiohttp (or any HTTP
client) using the paths in
Gateway REST API Endpoints. The client also ships
no WebSocket surface, so the connect / disconnect / on* handlers in
WebSocket Events are endpoint documentation for a raw
WebSocket connection rather than client methods.
| API surface | Python |
|---|---|
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?) |
not implemented — call the endpoint |
sendMessage(id, msg) |
send_message(id, msg) |
spawn(task, agent?) |
spawn(task, agent="") |
spawnMany(tasks, agents?) |
spawn_many(tasks, agents=None) |
listSubagents() |
client wrapper expects a bare list, but GET /api/spawn returns {agents}; call it directly and read agents |
getSubagentStatus(id) |
get_subagent_status(id) |
addCron(name, opts) |
add_cron(name, **opts) |
listCrons() |
client wrapper expects a bare list, but GET /api/crons returns {jobs, server_tz}; call it directly and read jobs |
updateCron(id, opts) |
client wrapper currently uses PUT, but the Gateway route is PATCH; call PATCH /api/crons/{id} directly |
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() |
client wrapper expects a bare list, but GET /api/lessons returns {lessons, total, ...}; call it directly and read lessons |
removeLesson(query) |
remove_lesson(query) |
sendNotification(text, opts?) |
send_notification(text, **opts) |
listNotifications() |
not implemented — call the endpoint |
ackNotifications() |
not implemented — call the endpoint |
approveAction(slot, task) |
not implemented — call the endpoint |
rejectAction(slot, task) |
not implemented — call the endpoint |
resolveApproval(id, ok) |
not implemented — call the endpoint |
getApprovalMode() |
not implemented — call the endpoint |
setApprovalMode(mode) |
not implemented — call the endpoint |
listModels() |
not implemented — call the endpoint |
setSlotModel(slot, model) |
not implemented — call the endpoint |
getGatewayConfig(key) |
not implemented — call the endpoint |
setGatewayConfig(key, val) |
not implemented — call the endpoint |
listMcpServers() |
client wrapper currently calls an unregistered path; call GET /api/mcp directly |
registerMcpServer(def) |
register_mcp_server(name, cmd, args?, env?) |
removeMcpServer(name) |
remove_mcp_server(name) |
registerAppMcp(name, entry) |
not implemented — call the endpoint |
unregisterAppMcp(name) |
not implemented — call the endpoint |
installAgentConfig(name, cfg) |
not implemented — call the endpoint |
removeAgentConfig(name) |
not implemented — call the endpoint |
installSkill(name, dir) |
not implemented — call the endpoint |
removeSkill(name) |
not implemented — call the endpoint |
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) |
The standalone package exports only KiroCrewClient, KiroCrewError, and
ErrorCode. It does not export AppManifest, AppLifecycle, GatewayManager,
proxy-auth helpers, or a WebSocket client. Validate manifests through the main
package's install path, manage the Gateway with the kirocrew CLI, and use
kiro_crew.apps.proxy_auth only from a backend that can import the main package.
All kirocrew-client errors are KiroCrewError instances with code,
message, status, body.
| Code | Trigger | Retried? |
|---|---|---|
AUTH_REQUIRED |
Remote connection without token | No |
AUTH_EXPIRED |
401/403 response | No (calls on_auth_expired if set) |
VALIDATION_ERROR |
Invalid input | No |
NOT_FOUND |
404 response | No |
RATE_LIMITED |
429 response | Yes (Retry-After or backoff) |
SERVER_ERROR |
5xx response | Yes (exponential backoff) |
NETWORK_ERROR |
Timeout or connection failure | Yes (exponential backoff) |
WS_DISCONNECTED |
Reserved enum value; the current client has no WebSocket surface and does not emit it | No |
from kirocrew_client import KiroCrewError
try:
await mc.send_message("slot-1", "hello")
except KiroCrewError as e:
print(e.code, e.message, e.status)The useAppApi() hook can call declared paths, while the source-only Python
client wraps the subset named below. These API routes require the appropriate
dashboard, app, or internal credential; a bare curl request is not authenticated.
| Method | Path | Description |
|---|---|---|
| GET | /api/status |
Gateway status and connectivity check |
| GET | /api/system |
System metrics |
| GET/POST | /api/chat/slots |
List or create chat slots |
| GET/DELETE | /api/chat/slots/{slot} |
Read slot detail/history or delete a slot |
| POST | /api/chat |
Send a chat turn |
| GET/POST | /api/spawn |
List or start subagents |
| GET | /api/spawn/{agent_id} |
Read subagent status |
| GET/POST | /api/crons |
List or create cron jobs |
| PATCH/DELETE | /api/crons/{job_id} |
Update or delete a cron job |
| POST | /api/crons/{job_id}/enable |
Pause or resume a cron job |
| GET/POST/DELETE | /api/lessons |
List, add, or remove lessons |
| POST | /api/send-message |
Send a notification/message |
| GET | /api/mcp |
List MCP server configuration |
| PUT/DELETE | /api/mcp/servers/{name} |
Register or remove an MCP server |
| GET | /api/memory/episodic/search |
Search memory |
| POST | /api/chat/slots/{slot}/context |
Inject silent context |
| Method | Path | Description |
|---|---|---|
| GET | /api/apps |
List all installed apps |
| GET | /api/apps/registry |
List available apps from registry |
| GET | /api/apps/blob?repo=&path=&ref= |
Proxy images from a registry app's git repo |
| POST | /api/apps/install |
Install from local path |
| POST | /api/apps/register |
Register a self-managed app |
| POST | /api/apps/registry/install |
Install from registry |
| POST | /api/apps/registry/install-stream |
Install from registry with an SSE progress stream |
| GET | /api/apps/{name} |
Get app details |
| GET | /api/apps/{name}/manifest |
Get app manifest |
| GET/PUT | /api/apps/{name}/config |
Read/write app config |
| POST | /api/apps/{name}/update |
Update installed app |
| POST | /api/apps/{name}/uninstall |
Uninstall app |
| POST | /api/apps/{name}/enable |
Enable app |
| POST | /api/apps/{name}/disable |
Disable app |
| POST | /api/apps/{name}/dev |
Toggle dev mode (live reload) — body {"enabled": bool} |
| POST | /api/apps/{name}/open |
Launch app via openCommand |
| GET | /apps/{name}/ui/{path} |
Serve app UI bundle files |
| * | /apps/{name}/api/{path} |
Reverse proxy to app backend (HMAC-signed) |
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.
A Python app backend whose environment can import kiro_crew (the built-in app backends run as child processes and still import it) verifies this with the gateway's own helper:
from kiro_crew.apps.proxy_auth import raw_request_target, verify_proxy_request
body = await request.read()
if not verify_proxy_request(
request.headers.get('X-KiroCrew-Proxy', ''),
method=request.method,
target=raw_request_target(request),
body=body,
):
return Response(status=401)Every argument after the header value is keyword-only. Pass the target through
raw_request_target: the gateway signs the request-target exactly as it went on
the wire, and rebuilding it from a decoded path diverges from the signed bytes as
soon as a query parameter carries a space or a non-ASCII character.
A backend that cannot import kiro_crew (a different language, or a Python
environment without the package) computes the HMAC itself, exactly as the Node.js
paragraph below describes.
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.
Body-bound signature: every verifier must bind
sha256(body)while keeping the constant-time compare and the ±60s freshness window. A gateway that signs body-bound HMACs fails verification against any verifier that omits the body hash, so a backend that implements the HMAC itself has to be updated in lockstep with the gateway.
The gateway spawns each backend.entryPoint app as a sandboxed child and injects a fixed,
generic set of environment variables. No app-specific variables are ever injected.
| Variable | Always set | Meaning |
|---|---|---|
PORT |
yes | The loopback port your backend must bind (127.0.0.1:$PORT). |
KIROCREW_APP_NAME |
yes | This app's installed name. |
KIROCREW_HOME |
yes | The gateway's resolved data home, so the backend reads the same app tree. |
KIROCREW_GATEWAY_ORIGIN |
only with bound-port evidence | The gateway's own origin, http://<bound host>:<bound port>, for calling back to the gateway (for example POST /api/notifications/push). It is set ONLY from the address the gateway ACTUALLY bound: its exported KIROCREW_BOUND_PORT (required to be numeric and in 1..65535), with the host 127.0.0.1 for loopback and wildcard binds and [::1] for an IPv6-loopback bind. A gateway bound to one specific interface omits the variable entirely: backend callbacks carry no Origin header, which the gateway's CSRF barrier trusts only from a loopback peer, so a specific-interface origin would have every mutating callback refused. It is never your app's PORT, an inherited KIROCREW_PORT, a config value, a default, or a request-derived value, so a child can never be pointed at a sibling gateway. Without loopback bound-address evidence the variable is omitted entirely (see below). |
KIROCREW_PROXY_SECRET |
only if a secret exists | The per-app secret used to verify the X-KiroCrew-Proxy header (see above). |
KIROCREW_GATEWAY_ORIGIN_PROOF |
only if a secret exists and the origin is set | HMAC-SHA256(app_secret, KIROCREW_GATEWAY_ORIGIN), hex. Recompute it with your secret to confirm the injected origin was minted by this gateway, rather than an inherited or spoofed env value. Omitted whenever the origin is omitted (nothing to prove) or no secret exists (nothing to key it with). |
Security and lifecycle:
- The per-app secret lives on disk at
<KIROCREW_HOME>/apps/<name>/.app_secret, written owner-only0600(owner-only DACL on Windows) by the gateway. - If no
.app_secretexists, the gateway injects neither the secret nor anything derived from it (including the proof); a secret-less backend is otherwise unchanged. KIROCREW_GATEWAY_ORIGINis fail-closed: it is present only when the gateway has real evidence of the port it bound. A gateway that has not exported a validKIROCREW_BOUND_PORThands the backend no origin, so a backend that needs a callback base stays dormant rather than trusting a guessed address.- The origin and its proof are recomputed on every spawn, so a gateway restarted on a different bound port hands the backend the current origin. That freshness guarantee is scoped to SPAWNED instances: an externally managed backend the gateway ADOPTS (already healthy on its port) keeps the environment of the generation that started it, so its origin can be stale. A backend that keeps a long-lived callback base should treat persistent push failures as a stale origin and restart to pick up the current one.
- The proof is a SPAWN-TIME attestation, not a liveness or freshness signal: it says the
origin value in your environment was planted by a gateway holding your
.app_secretwhen your process started. Because the secret persists across gateway generations, a stale origin (the adopted case above) still carries a valid proof — verifying the proof tells you the origin was not planted by a secret-less spawner, and nothing about whether that gateway is still the one serving. Do not use it as origin-trust for a long-lived process; use the push-failure/restart guidance above for that.
Using the origin for notifications:
An entryPoint backend that declares notifications.channels in app.json pushes with
POST {KIROCREW_GATEWAY_ORIGIN}/api/notifications/push, authenticating with its app secret
(see App Notifications). Verify KIROCREW_GATEWAY_ORIGIN_PROOF before you trust the origin as
your callback base. If KIROCREW_GATEWAY_ORIGIN is unset the gateway did not publish a
bound-port origin, so the backend has no callback base and should not push.
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 the whole ui/ directory —
~/.kiro/crew/apps/<name>/ui → your source tree — so the watcher sees edits at
the real files. Link the directory, never individual files inside it: the
UI route opens the final path component with O_NOFOLLOW (a swap-resistant
open), so a per-file symlink like ln -s ~/src/app/dist/index.mjs ui/index.mjs
answers 404 — indistinguishable from "not built yet". The directory link
works because the route resolves the ui root through the link before
validating files against it.
Contract surface:
installed.jsonfield —dev: bool(defaultfalse): persisted per-app flag. Tolerant on read (absent ⇒false); reversible; no migration needed. Builtin apps cannot enter dev mode. This field controls watching andno-storeserving only — it is app-writable metadata and never authorizes anything by itself (see the grant record below).- Endpoint —
POST /api/apps/{name}/dev, body{"enabled": <bool>}. Returns{"name": <name>, "dev": <bool>}.400for a non-boolean body, a builtin app, an unsafe app name, or a refused grant (see below);404if the app is not installed. Behind the standard gateway auth; emits anapp_dev_modeSEL audit event. The endpoint deliberately has no field to confirm an out-of-install root — that confirmation is CLI-only (below). - WebSocket event —
app_reload, payload{"app": <name>, "ts": <float>}. Re-dispatched to the frontend as themc:app-reloadwindow CustomEvent; the AppHost triggers a full page reload for the matching app. - CLI —
kirocrew app dev <name> [--off] [--confirm-out-of-install-root]: toggles the flag out-of-process; the gateway watcher picks up the change within one poll interval, so no gateway restart is needed.
Enabling dev mode also records an operator grant: a file at the apps root
(~/.kiro/crew/apps/.dev-grants.json) mapping the app name to the ui root's
resolved path at toggle time (realpath of <install>/ui). It is written
only by the dev-mode toggle (and revoked on disable/uninstall) — never by
the gateway's startup reconcile, and never derived from installed.json. The
UI route requires it before serving a ui root that resolves outside the
app's install directory: without a grant that exactly matches the current
resolved root, out-of-install files answer 400.
Two files, two jobs: installed.json dev (plus an internal sentinel cache,
below) drives watching and cache headers; the grant record is the
authorization. An app can write dev: true into its own metadata, but it
cannot mint a grant — that separation is what stops an app from pointing ui
at an arbitrary directory and having the UI route serve it.
Because the grant binds one exact resolved root, it is self-invalidating:
repointing ui after the toggle (an app update, a swapped link, a reinstall
under the same name) yields a root that no longer equals the granted one, and
the route answers 400 for those files until the operator re-toggles.
Re-toggle after re-pointing is the workflow — run the toggle again (enable
while already enabled is fine) to bind the grant to the new root. The same
applies after upgrading from a gateway version that predates the grant record:
an app already in dev mode on an out-of-install root has no grant, so its UI
answers 400 until one re-toggle.
The toggle validates the resolved ui root before writing anything (a refusal never disturbs existing state):
- Sensitive roots are never grantable. A root that resolves into a
sensitive location (credential stores, key material) or contains sensitive
leaves at toggle time is refused outright with
400and an error naming the resolved root — no confirmation can override this. The screen is point-in-time: it inspects the tree as it exists when the toggle runs, and serving afterwards re-checks only that the resolved root still equals the granted one. Confirming a grant approves the tree location, not a permanent screen of its future contents. - Out-of-install roots are refused over HTTP; confirm from the host.
App UI bundles run as same-origin modules with the dashboard's own
credentials, so a request-body flag can never prove operator intent — the
endpoint therefore has no confirmation field at all. Enabling dev mode on a
root outside the install directory always answers
400withcode: "dev_mode_out_of_install_confirmation_required"and an error naming the fix: runkirocrew app dev <name> --confirm-out-of-install-rooton the gateway host. The CLI is the confirmation boundary because running it requires the operator's own process on the host — a boundary page code cannot cross. This gate is a fail-closed default that blocks self-granting and unwitting scripted callers; the load-bearing serving guarantees remain the resolved-root equality binding and the sensitivity screen. Roots inside the install directory need no confirmation. - The flag is operator-only on the agent side too — three tiers. First,
the builtin agent deny rule
self-protection-dev-mode-out-of-root-confirmrefuses any agent shell command carrying the flag — matched both as literal text and, via the rule's argv floor, on the shell-de-escaped command, so quote-splitting the token (--confirm-out-of-install-'root') is denied the same as the plain spelling; thedevsubparser is built withallow_abbrev=False, so argparse rejects abbreviated spellings (--confirm) that would otherwise reach the flag without its literal text ever appearing. Second — because a command can synthesize the flag at runtime ($(printf ...)) so that no command-text scan sees it — the flag's consumption point performs a runtime human-vs-agent check: a process showing evidence of agent-shell confinement (the launcher-set sandbox marker, or on macOS the kernel's own Seatbelt verdict) is refused withcode: "dev_mode_operator_attestation_required". Third — because an environment can be scrubbed — the grant record itself (~/.kiro/crew/apps/.dev-grants.json) is sealed read-only inside the agent OS sandbox (Seatbelt / mount namespaces, alongside the other keystone ceilings), so a sandboxed process cannot mint, extend, or rewrite a grant no matter how the toggle is spelled; the gateway materializes the record at startup so the seal always has a target, and any grant-touching toggle from a process that cannot write the record is refused up front (code: "dev_mode_grant_record_readonly", SEL-audited) rather than half-applied — use the dashboard toggle from such a process. The confirmation must come from the operator's own terminal, which none of these tiers govern. - Both outcomes are audited. The unconfirmed refusal and the confirmed
grant each emit a security event log (SEL) entry
(
operation: dev_mode_out_of_install_grant, outcomedenied/granted, naming the resolved root); the granted event is written only after the grant record lands.
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 for the flag, and
the grant record as gateway-owned (written only through the toggle, never
directly).