| name | apps |
| description | Author and deploy LimaCharlie "apps" — self-contained AI-generated HTML mini-apps that render in a sandboxed iframe in the LimaCharlie web UI and call LimaCharlie APIs through a brokered, permission-scoped runtime (window.lc). Use when a user wants to create, edit, or understand a custom app/dashboard/tool embedded in the LimaCharlie console (the `app` hive). |
| allowed-tools | ["Bash","Read","Write"] |
LimaCharlie Apps
An app is a single self-contained HTML document (HTML + inline CSS + inline
JS) stored in the app hive. The LimaCharlie web UI renders it inside a
sandboxed iframe and injects a trusted runtime, window.lc, that brokers
LimaCharlie API calls using a JWT scoped to a subset of the viewing user's
permissions. You write only the app body — the runtime, base styles, and
security wrapper are injected by the host.
Full contract: web-app-frontend/docs/ai-guides/apps-runtime-contract.md.
The golden rules (apps are validated against these)
- Output is a single self-contained
<body> fragment. Do NOT emit
<html>, <head>, <base>, or <meta http-equiv> — the host owns those.
- No external resources: no
<script src>, external stylesheets, CDNs, or
web fonts. Inline everything. (The CSP blocks external loads.) For charts you
do NOT need a library — lc.chart (Chart.js) is injected for you.
- All LimaCharlie data goes through
lc.api(...). Never embed a token/API
key, never prompt the user for credentials.
- External network only to declared
allowed_origins via your own fetch.
Everything else is blocked.
- Style with the design system: compose
.lc-* classes and reference
--lc-* variables. Never hardcode colors or fonts (so dark mode works).
- Least privilege: request the fewest
required_permissions. Prefer
read-only (*.get, *.list). You can only declare permissions you yourself
hold. Sensitive perms (billing.ctrl, user.ctrl, apikey.ctrl) trigger a
severe warning to every viewer; write perms (*.set, *.del, *.task,
*.ctrl) trigger a lesser one.
- Declare every service you call. Every distinct
service you pass to
lc.api(..., { service }) MUST appear in the record's required_services,
or the call fails at runtime with denied — the HTML and the record are
validated separately, so nothing auto-syncs them. When you add, remove, or
change a { service } call, update required_services in the same edit.
(This is the #1 reason a working-looking app returns denied.)
The window.lc runtime
await lc.ready
lc.version
lc.ctx.user
lc.ctx.orgs
lc.ctx.context
lc.ctx.theme
await lc.api(method, path, body?, opts?)
lc.chart(target, spec)
lc.onThemeChange(theme => { })
path is a site-relative LC path under /v1, e.g. '/v1/who' or
'/v1/orgs/<oid>/...'. Absolute URLs / other hosts / writes to
/v1/hive/app/... are rejected.
- Other LC services. Some LimaCharlie APIs live off the main API on their own
hosts. Reach one by passing
opts.service AND listing it in required_services
(a service you didn't declare is rejected with denied). The parent host-pins
the call and brokers it with the same scoped JWT — it does NOT rewrite your
path, so use the EXACT path/method the service expects. Each service serves
its own OpenAPI — fetch it live and read it before writing calls; do not guess
or trust a path from memory (these drift). Valid services:
search — the historical-events query API (LCQL), the same backend as the
query console. Two steps: POST /v1/search/ with a JSON body
({ oid, query, startTime, endTime, ... }) to get a queryId, then poll
GET /v1/search/<queryId>/. This is the search service — NOT replay.
cases — case management. Base /api/v1/... (e.g. GET /api/v1/cases,
GET /api/v1/cases/{caseNumber}, GET /api/v1/dashboard/counts). Spec:
https://cases.limacharlie.io/openapi.
ai — AI Sessions / agents. Base /v1/... (e.g. GET /v1/sessions,
GET /v1/org/sessions). Host is ai-sessions.limacharlie.io (NOT
ai.limacharlie.io). Spec: https://ai-sessions.limacharlie.io/openapi.
replay — sensor telemetry replay (rarely needed; NOT the query API).
const init = await lc.api('POST', '/v1/search/',
{ oid, query, startTime, endTime }, { service: 'search' })
const page = await lc.api('GET', '/v1/search/' + init.queryId + '/',
null, { service: 'search' })
- On failure,
lc.api rejects with Error & { code, status }; code is one of
denied | rate_limited | unauthorized | http | timeout | aborted | malformed.
- Limits: ~10 req/s (burst 20), 8 concurrent, 256 KB body.
Design system
Tokens: --lc-bg --lc-surface --lc-line --lc-ink --lc-muted --lc-accent --lc-positive --lc-warning --lc-danger --lc-input-bg --lc-input-line --lc-font-sans --lc-font-mono --lc-radius --lc-space.
Classes: .lc-card .lc-btn (--primary/--danger) .lc-input .lc-select .lc-textarea .lc-label .lc-badge (--positive/--warning/--danger) .lc-table .lc-kpi (.lc-kpi__value/.lc-kpi__label) .lc-row .lc-col .lc-stack .lc-muted .lc-spinner.
Charts: use lc.chart(target, spec) — a themed Chart.js v4 wrapper the host
vendors into the iframe automatically (do NOT add a chart <script src>; it's
CSP-blocked). spec is { type, data, options } just like Chart.js; uncolored
datasets get the host palette and the chart re-themes on dark mode. Give the
canvas's container an explicit height.
<div class="lc-card" style="height:260px"><canvas id="c"></canvas></div>
<script>
(async () => {
await lc.ready
const oid = lc.ctx.orgs[0].oid
const s = await lc.api('GET', '/v1/sensors/' + oid)
const online = (s.sensors || []).filter((x) => x.is_online).length
lc.chart('c', {
type: 'doughnut',
data: { labels: ['Online', 'Offline'],
datasets: [{ data: [online, (s.sensors || []).length - online] }] },
})
})()
</script>
Golden reference app
A complete, conforming app body. Use it as the template.
<div class="lc-stack">
<div class="lc-card">
<div class="lc-row" style="justify-content:space-between">
<h2>Sensor status</h2>
<button class="lc-btn lc-btn--primary" id="refresh">Refresh</button>
</div>
<div id="status" class="lc-muted">Loading…</div>
</div>
<div class="lc-card">
<div class="lc-kpi">
<span class="lc-kpi__value" id="count">—</span>
<span class="lc-kpi__label">Sensors online</span>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id)
async function load() {
$('status').textContent = 'Loading…'
try {
await lc.ready
const oid = lc.ctx.orgs[0].oid
const res = await lc.api('GET', '/v1/sensors/' + oid)
const sensors = res.sensors || []
$('count').textContent = sensors.filter((s) => s.is_online).length
$('status').innerHTML =
'<span class="lc-badge lc-badge--positive">' + sensors.length + ' sensors</span>'
} catch (e) {
$('status').innerHTML =
'<span class="lc-badge lc-badge--danger">Error: ' + e.code + '</span>'
}
}
$('refresh').addEventListener('click', load)
load()
</script>
This app would declare required_permissions: ["sensor.list"] and no
allowed_origins.
Creating the app record
Apps live in the app hive (org-scoped). Authoring is done via the CLI; the web
UI is for running and managing apps, not editing HTML.
-
Validate the HTML against the golden rules above before writing it.
-
Write the record JSON to a file (the data payload mirrors the backend
AppRecord):
cat > /tmp/app.json << 'EOF'
{
"display_name": "Sensor status",
"description": "Live count of online sensors",
"icon": "🛰️",
"html": "<...the app body from above...>",
"required_permissions": ["sensor.list"],
"allowed_origins": [],
"required_services": [],
"locations": ["standalone"],
"expected_context": []
}
EOF
locations: any of standalone, within_a_sensor, within_a_detection,
within_a_case, within_a_dr_rule. For embeds, list the identifiers you
read from lc.ctx.context in expected_context (e.g. ["sid"]).
allowed_origins: only https://host origins (no path), and only if the
app must reach an external (third-party) API directly. Empty = LC only (safest).
required_services: first-party LC services the app calls via
lc.api(..., { service }) — any of search, replay, cases, ai. Empty
= main API only. Declaring a service is required before the app can call it.
-
Set the record (the --key is the app's stable id/slug):
limacharlie hive set --hive-name app --key sensor-status --input-file /tmp/app.json --oid <oid>
-
Enable it — hive records are NOT auto-enabled on set:
limacharlie hive enable --hive-name app --key sensor-status --oid <oid>
-
Manage from the UI: Apps in the org sidebar (/orgs/<oid>/apps), or the
matching object-context page for embeds.
Common pitfalls
- Author lacks a declared permission → the
set is rejected. You can only
require perms you hold.
- App "works for me" but not for an admin is impossible by design: at view
time the JWT is
required ∩ viewer's perms, so it can only ever shrink.
- Forgot to enable → the app won't appear/run. Run
hive enable.
- External fetch fails silently → the origin isn't in
allowed_origins
(CSP blocked it). Add it, or route via lc.api if it's LC data.
lc.api(..., { service }) rejected with denied → the service isn't in
required_services (or the org can't resolve it). Add it to the record.
- Unstyled / wrong colors → you hardcoded styles or loaded an external
sheet. Use
.lc-* / --lc-*.
- Chart is blank / invisible → its container has no height. Put the
<canvas> in a sized box (e.g. style="height:260px"). Don't add an external
chart library — lc.chart (Chart.js) is already injected.