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 // wait for the secure handshake
lc.version // '1'
lc.ctx.user // { id, email, displayName }
lc.ctx.orgs // [{ oid, name }]
lc.ctx.context // embed identifiers, e.g. { sid }
lc.ctx.theme // { mode, vars } (presentation only)
await lc.api(method, path, body?, opts?) // brokered LC API call -> JSON
lc.chart(target, spec) // themed Chart.js wrapper (see Charts)
lc.onThemeChange(theme => { /* ... */ }) // live dark-mode updates
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 || []).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.
1---2name: apps3description: 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).4---56# LimaCharlie Apps78An **app** is a single self-contained HTML document (HTML + inline CSS + inline9JS) stored in the `app` hive. The LimaCharlie web UI renders it inside a10**sandboxed iframe** and injects a trusted runtime, `window.lc`, that brokers11LimaCharlie API calls using a JWT scoped to a subset of the viewing user's12permissions. You write **only the app body** — the runtime, base styles, and13security wrapper are injected by the host.1415Full contract: `web-app-frontend/docs/ai-guides/apps-runtime-contract.md`.1617## The golden rules (apps are validated against these)18191. Output is a **single self-contained `<body>` fragment**. Do NOT emit20 `<html>`, `<head>`, `<base>`, or `<meta http-equiv>` — the host owns those.212. **No external resources**: no `<script src>`, external stylesheets, CDNs, or22 web fonts. Inline everything. (The CSP blocks external loads.) For charts you23 do NOT need a library — `lc.chart` (Chart.js) is injected for you.243. **All LimaCharlie data goes through `lc.api(...)`.** Never embed a token/API25 key, never prompt the user for credentials.264. **External network only to declared `allowed_origins`** via your own `fetch`.27 Everything else is blocked.285. **Style with the design system**: compose `.lc-*` classes and reference29 `--lc-*` variables. Never hardcode colors or fonts (so dark mode works).306. **Least privilege**: request the fewest `required_permissions`. Prefer31 read-only (`*.get`, `*.list`). You can only declare permissions you yourself32 hold. Sensitive perms (`billing.ctrl`, `user.ctrl`, `apikey.ctrl`) trigger a33 severe warning to every viewer; write perms (`*.set`, `*.del`, `*.task`,34 `*.ctrl`) trigger a lesser one.357. **Declare every service you call.** Every distinct `service` you pass to36 `lc.api(..., { service })` MUST appear in the record's `required_services`,37 or the call fails at runtime with `denied` — the HTML and the record are38 validated separately, so nothing auto-syncs them. When you add, remove, or39 change a `{ service }` call, update `required_services` **in the same edit**.40 (This is the #1 reason a working-looking app returns `denied`.)4142## The `window.lc` runtime4344```js45await lc.ready // wait for the secure handshake46lc.version // '1'47lc.ctx.user // { id, email, displayName }48lc.ctx.orgs // [{ oid, name }]49lc.ctx.context // embed identifiers, e.g. { sid }50lc.ctx.theme // { mode, vars } (presentation only)51await lc.api(method, path, body?, opts?) // brokered LC API call -> JSON52lc.chart(target, spec) // themed Chart.js wrapper (see Charts)53lc.onThemeChange(theme => { /* ... */ }) // live dark-mode updates54```5556- `path` is a site-relative LC path under `/v1`, e.g. `'/v1/who'` or57 `'/v1/orgs/<oid>/...'`. Absolute URLs / other hosts / writes to58 `/v1/hive/app/...` are rejected.59- **Other LC services.** Some LimaCharlie APIs live off the main API on their own60 hosts. Reach one by passing `opts.service` AND listing it in `required_services`61 (a service you didn't declare is rejected with `denied`). The parent host-pins62 the call and brokers it with the same scoped JWT — it does NOT rewrite your63 path, so use the EXACT path/method the service expects. **Each service serves64 its own OpenAPI — fetch it live and read it before writing calls; do not guess65 or trust a path from memory (these drift).** Valid services:66 - `search` — the historical-events query API (LCQL), the same backend as the67 query console. Two steps: POST `/v1/search/` with a JSON body68 (`{ oid, query, startTime, endTime, ... }`) to get a `queryId`, then poll69 GET `/v1/search/<queryId>/`. This is the `search` service — NOT `replay`.70 - `cases` — case management. Base `/api/v1/...` (e.g. GET `/api/v1/cases`,71 GET `/api/v1/cases/{caseNumber}`, GET `/api/v1/dashboard/counts`). Spec:72 `https://cases.limacharlie.io/openapi`.73 - `ai` — AI Sessions / agents. Base `/v1/...` (e.g. GET `/v1/sessions`,74 GET `/v1/org/sessions`). Host is `ai-sessions.limacharlie.io` (NOT75 `ai.limacharlie.io`). Spec: `https://ai-sessions.limacharlie.io/openapi`.76 - `replay` — sensor *telemetry* replay (rarely needed; NOT the query API).77 ```js78 const init = await lc.api('POST', '/v1/search/',79 { oid, query, startTime, endTime }, { service: 'search' })80 const page = await lc.api('GET', '/v1/search/' + init.queryId + '/',81 null, { service: 'search' })82 ```83- On failure, `lc.api` rejects with `Error & { code, status }`; `code` is one of84 `denied | rate_limited | unauthorized | http | timeout | aborted | malformed`.85- Limits: ~10 req/s (burst 20), 8 concurrent, 256 KB body.8687## Design system8889Tokens: `--lc-bg --lc-surface --lc-line --lc-ink --lc-muted --lc-accent90--lc-positive --lc-warning --lc-danger --lc-input-bg --lc-input-line91--lc-font-sans --lc-font-mono --lc-radius --lc-space`.9293Classes: `.lc-card .lc-btn (--primary/--danger) .lc-input .lc-select94.lc-textarea .lc-label .lc-badge (--positive/--warning/--danger) .lc-table95.lc-kpi (.lc-kpi__value/.lc-kpi__label) .lc-row .lc-col .lc-stack .lc-muted96.lc-spinner`.9798Charts: use `lc.chart(target, spec)` — a themed **Chart.js v4** wrapper the host99vendors into the iframe automatically (do NOT add a chart `<script src>`; it's100CSP-blocked). `spec` is `{ type, data, options }` just like Chart.js; uncolored101datasets get the host palette and the chart re-themes on dark mode. Give the102canvas's container an explicit height.103104```html105<div class="lc-card" style="height:260px"><canvas id="c"></canvas></div>106<script>107 (async () => {108 await lc.ready109 const oid = lc.ctx.orgs[0].oid110 const s = await lc.api('GET', '/v1/sensors/' + oid)111 const online = (s.sensors || []).filter((x) => x.is_online).length112 lc.chart('c', {113 type: 'doughnut',114 data: { labels: ['Online', 'Offline'],115 datasets: [{ data: [online, (s.sensors || []).length - online] }] },116 })117 })()118</script>119```120121## Golden reference app122123A complete, conforming app body. Use it as the template.124125```html126<div class="lc-stack">127 <div class="lc-card">128 <div class="lc-row" style="justify-content:space-between">129 <h2>Sensor status</h2>130 <button class="lc-btn lc-btn--primary" id="refresh">Refresh</button>131 </div>132 <div id="status" class="lc-muted">Loading…</div>133 </div>134135 <div class="lc-card">136 <div class="lc-kpi">137 <span class="lc-kpi__value" id="count">—</span>138 <span class="lc-kpi__label">Sensors online</span>139 </div>140 </div>141</div>142143<script>144 const $ = (id) => document.getElementById(id)145146 async function load() {147 $('status').textContent = 'Loading…'148 try {149 await lc.ready150 const oid = lc.ctx.orgs[0].oid151 const res = await lc.api('GET', '/v1/sensors/' + oid)152 const sensors = res.sensors || []153 $('count').textContent = sensors.filter((s) => s.is_online).length154 $('status').innerHTML =155 '<span class="lc-badge lc-badge--positive">' + sensors.length + ' sensors</span>'156 } catch (e) {157 $('status').innerHTML =158 '<span class="lc-badge lc-badge--danger">Error: ' + e.code + '</span>'159 }160 }161162 $('refresh').addEventListener('click', load)163 load()164</script>165```166167This app would declare `required_permissions: ["sensor.list"]` and no168`allowed_origins`.169170## Creating the app record171172Apps live in the `app` hive (org-scoped). Authoring is done via the CLI; the web173UI is for running and managing apps, not editing HTML.1741751. **Validate the HTML against the golden rules above** before writing it.1762. Write the record JSON to a file (the `data` payload mirrors the backend177 `AppRecord`):178179 ```bash180 cat > /tmp/app.json << 'EOF'181 {182 "display_name": "Sensor status",183 "description": "Live count of online sensors",184 "icon": "🛰️",185 "html": "<...the app body from above...>",186 "required_permissions": ["sensor.list"],187 "allowed_origins": [],188 "required_services": [],189 "locations": ["standalone"],190 "expected_context": []191 }192 EOF193 ```194195 - `locations`: any of `standalone`, `within_a_sensor`, `within_a_detection`,196 `within_a_case`, `within_a_dr_rule`. For embeds, list the identifiers you197 read from `lc.ctx.context` in `expected_context` (e.g. `["sid"]`).198 - `allowed_origins`: only `https://host` origins (no path), and only if the199 app must reach an external (third-party) API directly. Empty = LC only (safest).200 - `required_services`: first-party LC services the app calls via201 `lc.api(..., { service })` — any of `search`, `replay`, `cases`, `ai`. Empty202 = main API only. Declaring a service is required before the app can call it.2032043. **Set** the record (the `--key` is the app's stable id/slug):205206 ```bash207 limacharlie hive set --hive-name app --key sensor-status --input-file /tmp/app.json --oid <oid>208 ```2092104. **Enable it** — hive records are NOT auto-enabled on set:211212 ```bash213 limacharlie hive enable --hive-name app --key sensor-status --oid <oid>214 ```2152165. Manage from the UI: **Apps** in the org sidebar (`/orgs/<oid>/apps`), or the217 matching object-context page for embeds.218219## Common pitfalls220221- **Author lacks a declared permission** → the `set` is rejected. You can only222 require perms you hold.223- **App "works for me" but not for an admin** is impossible by design: at view224 time the JWT is `required ∩ viewer's perms`, so it can only ever shrink.225- **Forgot to enable** → the app won't appear/run. Run `hive enable`.226- **External fetch fails silently** → the origin isn't in `allowed_origins`227 (CSP blocked it). Add it, or route via `lc.api` if it's LC data.228- **`lc.api(..., { service })` rejected with `denied`** → the service isn't in229 `required_services` (or the org can't resolve it). Add it to the record.230- **Unstyled / wrong colors** → you hardcoded styles or loaded an external231 sheet. Use `.lc-*` / `--lc-*`.232- **Chart is blank / invisible** → its container has no height. Put the233 `<canvas>` in a sized box (e.g. `style="height:260px"`). Don't add an external234 chart library — `lc.chart` (Chart.js) is already injected.