---
name: apps
description: Build self-contained Dench Apps — `.dench.app` web apps that run inside the workspace and talk to the CRM, analytics, files, chat, integrations, notifications, billing and the full /api/v1 surface through the injected `window.dench` SDK. This is the contract — the method + permission index is generated from the runtime and cannot drift. Covers the recipe (create → write → validate), analytics-first data access (compute before objects.query), maps via dench.geo, dench.api for every v1 operation, real inbound webhooks, permissions, design tokens, theme sync, and the create/install/publish lifecycle. Load before building an in-workspace app or chat app widget.
---

# Dench Apps

A Dench App is a folder ending in `.dench.app/` under the workspace's `apps/`
directory — a `.dench.yaml` manifest plus `index.html` (inline CSS/JS). No
build step, no deploy, no runtime package. The host injects `window.dench`
into the iframe; every method is a Promise.

```
apps/
  pipeline-health.dench.app/
    .dench.yaml     # name, icon, permissions, entry
    index.html      # the bridge is auto-injected; inline everything
```

```yaml
name: Pipeline Health
icon: bar-chart
description: Stage mix, win rate and where deals are
entry: index.html
permissions:
  - crm:read
  - ui
```

`window.dench.version` is the SDK semver (`2.x`); `window.dench.capabilities`
lists every curated method. Typed autocomplete comes from the CLI package
already in every sandbox (types only — there is **no** `@dench.com/app-sdk`):

```js
/// <reference types="@dench.com/cli/app-sdk" />
/** @type {import("@dench.com/cli/app-sdk").Dench} */
const dench = window.dench;
```

## The recipe (do this, in this order)

1. `load_skill apps` (this file). For CRM shapes also read `references/CRM.md`.
2. `dench apps create <slug> --name "…" --icon <lucide-name>` — scaffolds a
   working app (`analytics.compute` + `objects.query` + theme sync) and
   registers it. Never hand-create the folder.
3. `write_file` `/apps/<slug>.dench.app/index.html` — one file, inline
   CSS/JS, the design tokens below, real loading/empty/error states.
4. Need something the aliases don't cover? **Discover, never invent**:
   `await dench.api.operations({ group: "crm" })` lists every callable
   operation with its `permission`, `cli` and `path`; `references/API.md`
   is the same catalog. Call it as `dench.api.call("crm.query", args)` or
   `dench.api.crm.query(args)`.
5. `dench apps validate /apps/<slug>.dench.app` — parses the manifest, scans
   the JS for every `dench.*` call, and fails on a method that does not exist
   or a permission the manifest does not declare. Fix, re-run, then open it.

**One donut in chat → `present_artifact({ kind: "analytics" })`, not an
app.** A custom dashboard, a combo/scatter, a multi-object widget, or an
interactive tool → an app that still gets every number from
`dench.analytics.compute`.

## Errors

Every rejection is a `DenchBridgeError` (`err.name === "DenchBridgeError"`)
with a stable `code`:

| `code` | Meaning | Fix |
|---|---|---|
| `permission_denied` | Method/operation needs a scope the manifest lacks (`err.permission`). | Add it to `.dench.yaml` `permissions`. |
| `unknown_method` | No such `dench.<ns>.<method>`; message lists that namespace's real methods. | Use the index below / `api.operations()`. |
| `unknown_operation` | `api.call` id is not a public `/api/v1` operation. | `api.operations()`. |
| `unsupported_local` | Operation only exists in the CLI (`sessions`, `fs`, `crm import`). | Not available to apps. |
| `validation_failed` | Args did not match the operation schema (`err.details`). | Check the request type in `app-sdk.d.ts`. |
| `unavailable` | Host surface lacks a handler (e.g. `ui.toast` in a headless surface). | Feature-detect and degrade. |
| `deprecated` | Removed API (`db.query`). | Use the replacement in the message. |
| `timeout` | 30 s (300 s for streaming `chat.send`). | Retry / narrow the request. |
| `upstream_failed` | Convex / gateway returned an error (`err.status`, message). | Show it. |

Messages still start with the code (`permission_denied: …`) so string checks
keep working.

## Getting data into your app (strict order)

Stop at the first rung that fits:

1. **Anything you will DRAW or SUM → `dench.analytics.compute`.** Never
   `objects.query` a page of rows and aggregate in JS: it is slow, misses
   rows past page 1 (1000 cap, no offset) and disagrees with the strip.
2. **CRM rows → `dench.objects.query` / `dench.objects.list`.** Filter
   server-side. Comments, tasks, watchers, views: `dench.comments.*`,
   `dench.tasks.*`, `dench.watch.*`, `dench.views.*`.
3. **Everything else Dench knows → `dench.<alias>` or `dench.api.*`**:
   notifications, members, meetings, lists, campaigns, email, billing,
   approvals, memory, workspace, files, chat history, App Store, search /
   web / images (gateway).
4. **Connected apps (Stripe, Gmail, Slack, GitHub, …) →
   `dench.integrations.*`.**
5. **`dench.http.fetch` — LAST RESORT**, public unauthenticated APIs only.
   No credentials are attached; it cannot reach `dench.com/api/*`. Never
   embed keys in app source.
6. **`dench.chat.send` — AI reasoning only** (summarize, draft, coach).
   Never a data pipe.

Apps must go through the bridge: same-origin `fetch("/api/…")` from app code
is unsupported and breaks when apps move to their own origin.

## Analytics: `compute` first (KPIs, charts, maps)

`dench.analytics.compute` runs `computePanels` — the exact query behind the
dashboard strip above every table: same numbers, same visibility rules, same
`period` and click-to-filter semantics. Counts, sums, averages, group-bys,
month-or-coarser time series and every derived metric (won/lost/open $,
MRR/ARR, forecast, win rate) read pre-computed buckets and are exact at any
table size (`source: "aggregate"` on the panel). Anything else scans the
newest rows; on a table too large for that scan the response says
`truncated: true` and `snapshot.stale: true`, a background pass over every
row is kicked off, and the next call is exact (`source: "snapshot"`).
**Period expansion is server-side**; never hand-build date filters.

```js
// Stored dashboard (+ the view's own dashboard when viewName is set)
const cfg = await dench.analytics.get("deal");
cfg.panels; cfg.fieldRoles; cfg.collapsed;

// Stored panels, this month vs last month (previousValue), ad-hoc extras
const res = await dench.analytics.compute("deal", {
  viewName: "West Coast",         // optional: follow a saved view's filters
  period: "this_month",           // this_week | this_month | last_month |
                                  // this_quarter | last_quarter | this_year | all
  panelIds: ["tpl:sales:open-pipeline"],   // stored panels (omit for all)
  panels: [                                // ad-hoc, nothing persisted
    { id: "by-stage", title: "By stage", type: "donut", metric: "sum",
      metricField: "Amount", groupBy: "Stage", sortOrder: 0 },
    { id: "owners", title: "Owners", type: "leaderboard", metric: "count",
      groupBy: "Owner", sortOrder: 1 },
    { id: "new", title: "New / week", type: "line", metric: "count",
      dateField: "$createdAt", dateBucket: "week", sortOrder: 2 },
    { id: "where", title: "Where", type: "map", metric: "count",
      groupBy: "$location", sortOrder: 3 },
  ],
  // Click-to-filter chips, same as the strip. `dimension` is the bare
  // field the click was on (a panel's groupBy / dateField; "$location"
  // for a map). Every panel is scoped EXCEPT those drawn along that
  // dimension, so the clicked chart keeps its full picture:
  drillFilters: [{ dimension: "Stage",
    filter: { id: "drill", conjunction: "and",
      rules: [{ id: "bucket", field: "Stage", operator: "is", value: "Won" }] } }],
  // A map click: filter on `$location` (the resolved place every compute
  // row carries), never on res.locationRole — that may be a relation
  // path like "Account.HQ Location", which is not a column.
  //   { dimension: "$location", filter: { id: "d", conjunction: "and",
  //     rules: [{ id: "p", field: "$location", operator: "within", value: "California" }] } }
});
res.panels[0].buckets;      // [{ key, label, count, value, color? }]
res.panels[1].value;        // scalar; .previousValue when `period` is set
res.panels[2].series;       // [{ bucket, label, value, count, projected? }]
res.panels[2].seriesGroups; // groupBy split of a time series (top 5 + Other)
res.locationRole;           // which field fed `$location` (label only; filter on `$location`)
res.truncated;              // badge numbers "approximate" when true
```

Panel `type`: `stat` `bar` `line` `area` `donut` `funnel` `leaderboard`
`forecast` `map`; `metric`: `count` `sum` `avg` `min` `max` (+ `metricField`);
`derived`: `conversion_rate` `win_rate` `won_amount` `lost_amount`
`open_pipeline` `pipeline_forecast` `mrr` `arr` (need field roles —
`dench.analytics.roles(objectName, { amount, stage, wonValues, … })`).
Synthetic fields `$createdAt` and `$location` work wherever a field name goes.
Writers: `save` / `delete` / `reorder` (object or `viewName` dashboard — the
first save on an inheriting view forks it), `roles`, `collapse` / `expand`,
`scope(objectName, viewName, "own" | "shared")`. Full model in
`references/CRM.md` → Analytics.

Draw with hand-written SVG. Leaderboard / donut buckets carry the raw key —
`dench.ui.openEntry(objectName, id)` from a row, or re-`compute` with a
`drillFilters` chip on click.

### Maps: `compute` + `dench.geo` (never a geocoder)

A `map` panel returns place-string buckets (`buckets[].key` is the raw
`$location` value) plus `locationRole`. Placing them on shapes is the same
client pipeline the strip uses, exposed host-side — apps must not call an
external geocoder or ship a gazetteer:

```js
const country = await dench.geo.resolveLocation("Austin, TX"); // { iso2:"US", id:"840", name }
const regions = await dench.geo.loadAdmin1("US");              // GeoJSON features (what the map draws)
const state   = await dench.geo.resolveRegion("Austin, TX", "US"); // { id, name: "Texas", … }
```

`loadAdmin1(iso2)` returns first-level divisions (`properties.name`,
`postal`, `lat`, `lng`); `/geo/admin1/<ISO2>.json` and `/geo/places.json`
are also fetchable same-origin if you need the raw topology. Click-to-filter
on a shape: collect the raw place strings behind it and pass them as a
`drillFilters` chip on `locationRole`.

## CRM rows: `objects.query` vs `objects.list`

```js
// Server-side filter + sort. Resolves to an ARRAY of flat rows.
const customers = await dench.objects.query({
  object: "company",
  filter: { Type: "Customer", MRR: { $gt: 500 } },
  sort: [{ field: "MRR", dir: "desc" }],
  select: ["Name", "MRR", "Health"],
  limit: 200, // default 100, max 1000 — NO offset
});
customers[0].Name; customers[0].id; // flat: fields on the row, id at .id
```

Operators: bare value = equality; `$eq $ne $in $nin $gt $gte $lt $lte $exists
$startsWith $endsWith $contains`, nestable with `$and` / `$or`.

`objects.list(name, { page, pageSize, search })` is the only paginated
surface and also returns the view snapshot (`fields`, `statuses`,
`totalCount`). Loop `page` until `entries.length < pageSize`.

```js
await dench.objects.create("task", { Title: "Follow up" });
await dench.objects.update("deal", id, { Stage: "Won" });
const schema = await dench.objects.getSchema("deal"); // fields, enum options
```

## `dench.api` — the whole `/api/v1` surface

Every public operation the CLI and REST API expose (~300) is callable:

```js
await dench.api.call("crm.query", { object: "deal", filter: { Stage: "Won" } });
await dench.api.crm.analytics.compute({ objectName: "deal", period: "this_year" });
await dench.api.notifications.list({ filter: "unread" });
await dench.api.billing.status();

const ops = await dench.api.operations({ group: "meetings" });
// [{ id: "meetings.list", cli: "dench meetings list", method: "GET",
//    path: "/meetings", permission: "meetings:read", summary }]
```

Rules: path parameters are ordinary args (`{ objectName, entryId, … }`).
Local-only CLI operations reject with `unsupported_local`. The permission is
the operation's scope (`<group>:read` for GET, `<group>:write` otherwise —
see the table). Prefer the curated aliases; they are the same calls with
positional sugar.

## Chat, agent, tools

```js
const s = await dench.chat.createSession("Deal coach");
await dench.chat.send(s.id, "Summarise my top 5 deals", {
  onEvent: (e) => { if (e.type === "text-delta") out.textContent += e.data; },
});
const history = await dench.chat.getHistory(s.id);   // { thread, messages }
await dench.chat.isActive(s.id);                     // boolean
const run = await dench.agent.send("Draft a renewal email for Acme");
run.threadId; run.runId;                             // open with ui.navigate(`/?thread=${run.threadId}`)
await dench.tool.register("refreshBoard", () => reload(), { description: "Re-render" });
```

## Integrations (connected apps)

```js
const connections = await dench.integrations.list();
const found = await dench.integrations.search({ query: "list charges", toolkit: "stripe" });
const res = await dench.integrations.execute({ tool: "STRIPE_LIST_CHARGES", arguments: { limit: 100 } });
res.data;
```

`integrations:read` covers listing, searching and read-only tools
(FETCH/GET/LIST/SEARCH/READ/FIND). Anything else also needs
`integrations:write`. Rejections: `not_connected`, `account_selection_required`
(pass `connectedAccountId` from `list()`), `permission_denied`.

## Inbound webhooks (real)

```js
const hook = await dench.webhooks.register("stripe");
hook.url;    // https://<workspace>/api/apps/webhooks/<app>/stripe/<token>
hook.secret; // shown ONCE — give it to the sender for signing
dench.webhooks.on("stripe", (evt) => render(evt.body)); // polls while open
const backlog = await dench.webhooks.poll("stripe", { since: lastSeen });
```

Senders `POST` JSON to the URL. If they sign Stripe-style
(`X-Dench-Signature: sha256=<hmac(ts.body)>`, `X-Dench-Timestamp`), the event
carries `verified: true`. Events are org + app scoped and kept 7 days.

## UI, storage, events, inter-app bus

```js
await dench.ui.toast("Saved", { type: "success" });
await dench.ui.navigate("people");          // references/NAVIGATION.md paths
await dench.ui.openEntry("deal", id);
await dench.store.set("filters", { stage: "Won" });
dench.events.on("object.entry.updated", refresh);   // also created / deleted / theme.changed
await dench.bus.send("other-app", { hello: 1 });    // inter-app messaging
dench.bus.on((m) => console.log(m.from, m.message));
```

`dench.apps.*` is the **App Store** (`list`, `gallery`, `create`, `install`,
`publish`, `uninstall`, `enable`, `disable`, `pin`, `unpin`). `dench.apps.send`
/ `dench.apps.on` remain as deprecated aliases of `dench.bus` for one major
version.

## Permissions

Deny-by-default; declare exactly what you call and nothing is implicit
(`billing.topup` needs `billing:write`). Consented at install time. The
scopes below are generated from the runtime:

<!-- app-sdk:permissions:start -->
| Scope | Unlocks (`dench.*` aliases) | `dench.api` groups |
|---|---|---|
| `agent-config:read` | `agentConfig.show` | `agent-config` |
| `agent-config:write` | `agentConfig.edit` | `agent-config` |
| `agent:invoke` | `agent.send`, `tool.register` | — |
| `ai:chat` | `chat.createSession`, `chat.send`, `chat.getHistory`, `chat.getSessions`, `chat.abort`, `chat.isActive`, `chat.list`, `chat.search`, … (10) | `chat` |
| `approvals:read` | `approvals.list` | `approvals` |
| `approvals:write` | `approvals.request`, `approvals.decide` | `approvals` |
| `apps:message` | `bus.send`, `bus.on`, `bus.list`, `apps.send`, `apps.on` | — |
| `apps:read` | `apps.list`, `apps.gallery` | `apps` |
| `apps:write` | `apps.create`, `apps.install`, `apps.publish`, `apps.uninstall`, `apps.enable`, `apps.disable`, `apps.pin`, `apps.unpin` | `apps` |
| `auth:write` | — | `auth` |
| `billing:read` | `billing.status` | `billing` |
| `billing:write` | `billing.topup`, `billing.upgrade` | `billing` |
| `browser:read` | — | `browser` |
| `browser:write` | — | `browser` |
| `campaigns:read` | `campaigns.list`, `campaigns.get`, `campaigns.analytics`, `campaigns.events` | `campaigns` |
| `campaigns:write` | `campaigns.pause`, `campaigns.resume` | `campaigns` |
| `clipboard:read` | `clipboard.read` | — |
| `clipboard:write` | `clipboard.write` | — |
| `crm:read` | `objects.list`, `objects.query`, `objects.get`, `objects.getSchema`, `objects.getOptions`, `analytics.get`, `analytics.compute`, `comments.list`, … (21) | `crm` |
| `crm:write` | `objects.create`, `objects.update`, `objects.delete`, `objects.bulkDelete`, `analytics.save`, `analytics.update`, `analytics.hide`, `analytics.show`, … (36) | `crm` |
| `cron:schedule` | `cron.schedule`, `cron.list`, `cron.update`, `cron.run`, `cron.cancel` | `routines` |
| `email:read` | `email.messages`, `email.message`, `email.campaigns`, `email.campaign`, `email.identities` | `email` |
| `email:write` | `email.send` | `email` |
| `files:read` | `files.read`, `files.list`, `files.download` | `files` |
| `files:write` | `files.write`, `files.delete`, `files.mkdir`, `files.move` | `files` |
| `http:external` | `http.fetch` | — |
| `image:write` | `images.generate`, `images.edit` | `gateway` |
| `integrations:read` | `integrations.list`, `integrations.search`, `integrations.execute` | `gateway` |
| `integrations:write` | — | `gateway` |
| `lists:read` | `lists.list`, `lists.get` | `lists` |
| `lists:write` | `lists.rename`, `lists.delete` | `lists` |
| `meetings:read` | `meetings.list`, `meetings.transcript`, `meetings.actionItems`, `meetings.participants` | `meetings` |
| `meetings:write` | `meetings.addActionItems`, `meetings.setActionItemDone` | `meetings` |
| `members:read` | `members.get`, `members.presence`, `members.activity`, `members.tasks`, `members.me` | `members` |
| `members:write` | `members.updateMe` | `members` |
| `memory:read` | `memory.get`, `memory.search` | `memory` |
| `memory:write` | `memory.save` | `memory` |
| `notifications:read` | `notifications.list`, `notifications.unreadCount`, `notifications.getPreferences` | `notifications` |
| `notifications:write` | `notifications.read`, `notifications.readAll`, `notifications.archive`, `notifications.updatePreferences` | `notifications` |
| `search:read` | `search.web`, `search.contents`, `search.answer` | `gateway` |
| `store:read` | `store.get`, `store.list` | — |
| `store:write` | `store.set`, `store.delete`, `store.clear` | — |
| `ui` | `ui.toast`, `ui.navigate`, `ui.openEntry`, `ui.setTitle`, `ui.confirm`, `ui.prompt` | — |
| `web:read` | `web.scrape`, `web.brand`, `web.screenshot`, `web.extract` | `gateway` |
| `webhooks:receive` | `webhooks.register`, `webhooks.on`, `webhooks.poll`, `webhooks.list`, `webhooks.delete` | `apps` |
| `workspace:read` | `workspace.context`, `workspace.status`, `workspace.agents`, `workspace.artifacts`, `workspace.instructions` | `workspace` |
<!-- app-sdk:permissions:end -->

`<group>:write` implies `<group>:read`. Legacy names (`objects`, `database`,
`files`, `store`, `integrations`, `agent`, `cron`, `webhooks`, `clipboard`,
`apps`) are still honoured as aliases.

## Method index (generated)

Every curated method, its permission and what it wraps. Anything not here
and not in `dench.api.operations()` does not exist — do not invent it.

<!-- app-sdk:index:start -->
**`dench.app`**

| Method | Permission | Notes |
|---|---|---|
| `getManifest()` | — | The parsed .dench.yaml of this app. |
| `getTheme()` | — | Current workspace theme ("light" \| "dark"). |

**`dench.context`**

| Method | Permission | Notes |
|---|---|---|
| `getWorkspace()` | — | Workspace name/slug the app is running in. |
| `getAppInfo()` | — | App path, folder slug, granted permissions and manifest. |

**`dench.api`**

| Method | Permission | Notes |
|---|---|---|
| `call(operationId: K, args?: Req<K>)` | — | Call ANY public /api/v1 operation by id (e.g. "crm.query"). Permission is the operation's own scope. |
| `operations(opts?: { group?: string })` | — | Runtime catalog of every callable operation: id, cli, method, path, permission. |

**`dench.objects`**

| Method | Permission | Notes |
|---|---|---|
| `list(name: string, opts?: ObjectsListOptions)` | `crm:read` | Paged rows of an object (flat `id` + field-on-row shape). |
| `query(dsl: ObjectsQuery)` | `crm:read` | Filter/sort/select rows with the query DSL (max 1000). Aggregate with analytics.compute instead. |
| `get(name: string, entryId: string)` | `crm:read` | One entry by id. |
| `create(name: string, fields: Record<string, unknown>)` | `crm:write` | Create an entry. |
| `update(name: string, entryId: string, fields: Record<string, unknown>)` | `crm:write` | Patch an entry's fields. |
| `delete(name: string, entryId: string)` | `crm:write` | Delete one entry. |
| `bulkDelete(name: string, entryIds: string[])` | `crm:write` | Delete many entries. |
| `getSchema(name: string)` | `crm:read` | Object definition: fields, types, enum options, relations. |
| `getOptions(name: string, query?: string)` | `crm:read` | Relation picker options (id + label) for an object, optionally filtered. |

**`dench.analytics`**

| Method | Permission | Notes |
|---|---|---|
| `get(objectName: string, opts?: crm.analytics.get args)` | `crm:read` | Stored dashboard: panels, field roles, collapsed flag (+ the view's own dashboard when viewName is set). → `crm.analytics.get` |
| `compute(objectName: string, opts?: crm.analytics.compute args)` | `crm:read` | Pre-bucketed KPIs/charts/maps — the strip's own math. Stored dashboard, `panelIds`, or ad-hoc `panels`; `period` and `drillFilters` expand server-side. → `crm.analytics.compute` |
| `save(objectName: string, panelId: string, panel: crm.analytics.save args["panel"], opts?: crm.analytics.save args)` | `crm:write` | Create or update a stored panel (object dashboard, or the view's when viewName is set — first save forks an inheriting view). → `crm.analytics.save` |
| `update(objectName: string, panelId: string, patch: crm.analytics.update args["patch"], opts?: crm.analytics.update args)` | `crm:write` | Edit part of a stored panel (title, type, metric, groupBy, dateField, filters, derived, span, hidden, sortOrder …); null clears an optional key. → `crm.analytics.update` |
| `hide(objectName: string, panelId: string, opts?: crm.analytics.hide args)` | `crm:write` | Take a chart off the strip above the table; it stays in the full analytics view. → `crm.analytics.hide` |
| `show(objectName: string, panelId: string, opts?: crm.analytics.show args)` | `crm:write` | Bring a hidden chart back onto the strip. → `crm.analytics.show` |
| `resize(objectName: string, panelId: string, span: crm.analytics.resize args["span"], opts?: crm.analytics.resize args)` | `crm:write` | Set a card's width: span 2 (third) \| 3 (half) \| 4 (two thirds) \| 6 (full) \| null (default for the chart kind). → `crm.analytics.resize` |
| `delete(objectName: string, panelId: string, opts?: crm.analytics.delete args)` | `crm:write` | Delete a stored panel. → `crm.analytics.delete` |
| `reorder(objectName: string, panelIds: string[], opts?: crm.analytics.reorder args)` | `crm:write` | Persist the full panel order. → `crm.analytics.reorder` |
| `roles(objectName: string, fieldRoles: crm.analytics.roles args["fieldRoles"], opts?: crm.analytics.roles args)` | `crm:write` | Set the object's analytics setup shared by every chart and view: amount / stage / won-lost-converted / stageProbabilities / closeDate / recurrence / owner / location. Pass merge: true to change only the keys given (null clears one). → `crm.analytics.roles` |
| `collapse(objectName: string)` | `crm:write` | Collapse the team-shared strip for this object. → `crm.analytics.collapse` |
| `expand(objectName: string)` | `crm:write` | Expand the team-shared strip for this object. → `crm.analytics.expand` |
| `scope(objectName: string, viewName: string, mode: crm.analytics.scope args["mode"])` | `crm:write` | Flip a saved view between its "own" dashboard and the object's "shared" one. → `crm.analytics.scope` |

**`dench.comments`**

| Method | Permission | Notes |
|---|---|---|
| `list(objectName: string, entryId: string, opts?: crm.comments.list args)` | `crm:read` | Root comments on an entry (with reply previews), paged. → `crm.comments.list` |
| `replies(commentId: string, opts?: crm.comments.replies args)` | `crm:read` | Replies under a root comment. → `crm.comments.replies` |
| `get(commentId: string)` | `crm:read` | One comment. → `crm.comments.get` |
| `create(objectName: string, entryId: string, body: string, opts?: crm.comments.create args)` | `crm:write` | Post a markdown comment (mention members with [@Name](/?member=<userId>), Dench with [@Dench](/?member=dench)). → `crm.comments.create` |
| `update(commentId: string, body: string)` | `crm:write` | Edit a comment body. → `crm.comments.update` |
| `delete(commentId: string)` | `crm:write` | Delete a comment. → `crm.comments.delete` |
| `react(commentId: string, emoji: string)` | `crm:write` | Toggle an emoji reaction. → `crm.comments.react` |

**`dench.watch`**

| Method | Permission | Notes |
|---|---|---|
| `get(objectName: string, entryId: string)` | `crm:read` | Am I following this entry? → `crm.watch.get` |
| `set(objectName: string, entryId: string, watching?: boolean)` | `crm:write` | Follow (default) or unfollow an entry. → `crm.watch.set` |

**`dench.views`**

| Method | Permission | Notes |
|---|---|---|
| `list(objectName: string)` | `crm:read` | Saved views of an object. → `crm.views.list` |
| `get(objectName: string, viewName: string)` | `crm:read` | One saved view (filters, sort, columns, analytics). → `crm.views.get` |
| `entries(objectName: string, viewName: string, opts?: crm.views.entries args)` | `crm:read` | Rows as the saved view shows them. → `crm.views.entries` |
| `create(objectName: string, view: crm.views.create args)` | `crm:write` | Create a saved view. → `crm.views.create` |
| `update(objectName: string, viewName: string, patch: crm.views.update args)` | `crm:write` | Update a saved view. → `crm.views.update` |
| `delete(objectName: string, viewName: string)` | `crm:write` | Delete a saved view. → `crm.views.delete` |

**`dench.tasks`**

| Method | Permission | Notes |
|---|---|---|
| `mine(opts?: crm.tasks.mine args)` | `crm:read` | Open tasks assigned to me. → `crm.tasks.mine` |
| `forEntry(objectName: string, entryId: string)` | `crm:read` | Tasks linked to an entry. → `crm.tasks.forEntry` |
| `create(objectName: string, entryId: string, task: crm.tasks.createForEntry args)` | `crm:write` | Create a task on an entry. → `crm.tasks.createForEntry` |
| `complete(entryId: string)` | `crm:write` | Mark a task done. → `crm.tasks.complete` |
| `reopen(entryId: string)` | `crm:write` | Reopen a task. → `crm.tasks.reopen` |
| `setPriority(entryId: string, priority: string \| null)` | `crm:write` | Set a task's priority (one of the workspace's options) or clear it with null. → `crm.tasks.setPriority` |
| `subtasks(entryId: string)` | `crm:read` | A task's subtasks. → `crm.tasks.subtasks.list` |
| `createSubtask(entryId: string, subtask: crm.tasks.subtasks.create args)` | `crm:write` | Create a subtask under a task (one level deep; inherits the parent's record relations). → `crm.tasks.subtasks.create` |
| `setParent(entryId: string, parentEntryId: string \| null)` | `crm:write` | Nest a task under a parent, or promote it with null. → `crm.tasks.setParent` |
| `delete(entryId: string, subtasks?: "delete" \| "keep")` | `crm:write` | Delete a task; its subtasks go with it unless subtasks is "keep". → `crm.tasks.delete` |
| `getRepeat(entryId: string)` | `crm:read` | How a task repeats (rule, RRULE, trigger, ends, next due), or null. → `crm.tasks.repeat.get` |
| `setRepeat(entryId: string, repeat: crm.tasks.repeat.set args)` | `crm:write` | Make a task repeat or change how it repeats (JSON rule or RRULE). An undated task gets its due date anchored on the first matching day from today. → `crm.tasks.repeat.set` |
| `clearRepeat(entryId: string)` | `crm:write` | Stop a task repeating. → `crm.tasks.repeat.clear` |
| `skipRepeat(entryId: string)` | `crm:write` | Skip the current occurrence; the next one is created now. → `crm.tasks.repeat.skip` |
| `pauseRepeat(entryId: string)` | `crm:write` | Pause a repeating task. → `crm.tasks.repeat.pause` |
| `resumeRepeat(entryId: string)` | `crm:write` | Resume a paused repeating task. → `crm.tasks.repeat.resume` |
| `series(opts?: crm.tasks.series.list args)` | `crm:read` | Every repeating-task series in the workspace. → `crm.tasks.series.list` |
| `seriesOccurrences(seriesId: string)` | `crm:read` | Every occurrence of one series. → `crm.tasks.series.occurrences` |

**`dench.notifications`**

| Method | Permission | Notes |
|---|---|---|
| `list(opts?: notifications.list args)` | `notifications:read` | My notifications (inbox \| unread \| archived \| all \| mentions). → `notifications.list` |
| `unreadCount()` | `notifications:read` | Unread badge count. → `notifications.unreadCount` |
| `read(notificationId: string, read?: boolean)` | `notifications:write` | Mark one read (or unread). → `notifications.read` |
| `readAll()` | `notifications:write` | Mark everything read. → `notifications.readAll` |
| `archive(notificationId: string)` | `notifications:write` | Archive one notification. → `notifications.archive` |
| `getPreferences()` | `notifications:read` | My notification preferences. → `notifications.preferences.get` |
| `updatePreferences(patch: notifications.preferences.update args)` | `notifications:write` | Update my notification preferences. → `notifications.preferences.update` |

**`dench.members`**

| Method | Permission | Notes |
|---|---|---|
| `get(userId: string)` | `members:read` | A member's profile. → `members.get` |
| `presence()` | `members:read` | Who is online / away / offline. → `members.presence` |
| `activity(userId: string, opts?: members.activity args)` | `members:read` | A member's recent CRM activity. → `members.activity` |
| `tasks(userId: string, opts?: members.tasks args)` | `members:read` | A member's open tasks. → `members.tasks` |
| `me()` | `members:read` | My own profile. → `me.profile.get` |
| `updateMe(patch: me.profile.update args)` | `members:write` | Update my name / tagline / timezone. → `me.profile.update` |

**`dench.meetings`**

| Method | Permission | Notes |
|---|---|---|
| `list(opts?: meetings.list args)` | `meetings:read` | Meetings (CRM `meeting` rows), newest first. → `meetings.list` |
| `transcript(meetingEntryId: string, opts?: meetings.transcript.list args)` | `meetings:read` | Transcript segments of a meeting. → `meetings.transcript.list` |
| `actionItems(meetingEntryId: string)` | `meetings:read` | Action items of a meeting. → `meetings.actionItems.list` |
| `addActionItems(meetingEntryId: string, items: meetings.actionItems.add args["items"])` | `meetings:write` | Add action items to a meeting. → `meetings.actionItems.add` |
| `setActionItemDone(itemId: string, done: boolean)` | `meetings:write` | Check or uncheck an action item. → `meetings.actionItems.setDone` |
| `participants(meetingEntryId: string)` | `meetings:read` | Participants of a meeting. → `meetings.participants.list` |

**`dench.lists`**

| Method | Permission | Notes |
|---|---|---|
| `list(opts?: lists.list args)` | `lists:read` | Saved lead lists. → `lists.list` |
| `get(artifactId: string)` | `lists:read` | One saved list with its rows. → `lists.get` |
| `rename(artifactId: string, title: string)` | `lists:write` | Rename a list. → `lists.rename` |
| `delete(artifactId: string)` | `lists:write` | Delete a list. → `lists.delete` |

**`dench.campaigns`**

| Method | Permission | Notes |
|---|---|---|
| `list()` | `campaigns:read` | LinkedIn / multi-channel outreach campaigns. → `campaigns.list` |
| `get(sequenceId: string)` | `campaigns:read` | One campaign. → `campaigns.get` |
| `analytics(sequenceId: string)` | `campaigns:read` | Campaign funnel numbers. → `campaigns.analytics` |
| `events(sequenceId: string, opts?: campaigns.events args)` | `campaigns:read` | Campaign event log. → `campaigns.events` |
| `pause(sequenceId: string)` | `campaigns:write` | Pause a campaign. → `campaigns.pause` |
| `resume(sequenceId: string)` | `campaigns:write` | Resume a campaign. → `campaigns.resume` |

**`dench.email`**

| Method | Permission | Notes |
|---|---|---|
| `send(message: email.send args)` | `email:write` | Send one email from a verified identity. → `email.send` |
| `messages(opts?: email.message.list args)` | `email:read` | Sent messages. → `email.message.list` |
| `message(messageId: string)` | `email:read` | One sent message with delivery events. → `email.message.get` |
| `campaigns(opts?: email.campaign.list args)` | `email:read` | Email campaigns. → `email.campaign.list` |
| `campaign(campaignId: string)` | `email:read` | One email campaign. → `email.campaign.get` |
| `identities()` | `email:read` | Verified sending identities. → `email.identity.list` |

**`dench.billing`**

| Method | Permission | Notes |
|---|---|---|
| `status()` | `billing:read` | Plan, AI balance and usage for the workspace. → `billing.status` |
| `topup(opts?: billing.topup args)` | `billing:write` | Start an AI credit top-up (returns a Stripe URL). → `billing.topup` |
| `upgrade(opts?: billing.upgrade args)` | `billing:write` | Upgrade the plan (returns a Stripe Checkout URL or updates in place). → `billing.upgrade` |

**`dench.approvals`**

| Method | Permission | Notes |
|---|---|---|
| `list()` | `approvals:read` | Pending approvals in the workspace. → `workspace.approvals.list` |
| `request(request: approval.request args)` | `approvals:write` | Ask a human to approve an action. → `approval.request` |
| `decide(approvalId: string, decision: approval.decide args["decision"], opts?: approval.decide args)` | `approvals:write` | Approve or reject a pending approval. → `approval.decide` |

**`dench.memory`**

| Method | Permission | Notes |
|---|---|---|
| `get()` | `memory:read` | The workspace's saved memories (legacy shape). **Deprecated:** Use memory.search(query). |
| `search(query: string, opts?: memory.search args)` | `memory:read` | Full-text search over saved memories. → `memory.search` |
| `save(memory: memory.save args)` | `memory:write` | Save a memory under a stable key. → `memory.save` |

**`dench.workspace`**

| Method | Permission | Notes |
|---|---|---|
| `context()` | `workspace:read` | What this workspace has: objects, agents, integrations, next commands. → `workspace.context` |
| `status()` | `workspace:read` | Workspace status snapshot. → `workspace.status` |
| `agents()` | `workspace:read` | Agents in this workspace. → `workspace.agents` |
| `artifacts(opts?: workspace.artifacts.list args)` | `workspace:read` | Recent artifacts (reports, docs, lists). → `workspace.artifacts.list` |
| `instructions()` | `workspace:read` | Workspace instructions the agent follows. → `workspace.instructions` |

**`dench.search`**

| Method | Permission | Notes |
|---|---|---|
| `web(query: string, opts?: search.web args)` | `search:read` | Web search (gateway). → `search.web` |
| `contents(opts: search.contents args)` | `search:read` | Fetch page contents for search results. → `search.contents` |
| `answer(query: string, opts?: search.answer args)` | `search:read` | Answer a question from web sources. → `search.answer` |

**`dench.web`**

| Method | Permission | Notes |
|---|---|---|
| `scrape(url: string, opts?: web.scrape args)` | `web:read` | Scrape a URL to Markdown. → `web.scrape` |
| `brand(domain: string, opts?: web.brand args)` | `web:read` | Brand kit (logo, colors, fonts) for a domain. → `web.brand` |
| `screenshot(url: string, opts?: web.screenshot args)` | `web:read` | Screenshot a rendered page. → `web.screenshot` |
| `extract(opts: web.extract args)` | `web:read` | Extract structured data from a page. → `web.extract` |

**`dench.images`**

| Method | Permission | Notes |
|---|---|---|
| `generate(opts: image.generate args)` | `image:write` | Generate an image from a prompt. → `image.generate` |
| `edit(opts: image.edit args)` | `image:write` | Edit an image with a prompt. → `image.edit` |

**`dench.agentConfig`**

| Method | Permission | Notes |
|---|---|---|
| `show(section: AgentConfigSection)` | `agent-config:read` | Read one config section: "identity" \| "organisation" \| "user" \| "tools" \| "mem" \| "bootstrap". |
| `edit(section: AgentConfigSection, content: string)` | `agent-config:write` | Replace one config section's markdown. |

**`dench.files`**

| Method | Permission | Notes |
|---|---|---|
| `read(path: string)` | `files:read` | Read a workspace file (text inline; binary → `downloadable: true`). → `files.content` |
| `list(dir?: string)` | `files:read` | List a directory (default /). → `files.list` |
| `write(path: string, content: string, opts?: files.write args)` | `files:write` | Write a UTF-8 text file (creates parents). → `files.write` |
| `delete(path: string)` | `files:write` | Delete a file or directory. → `files.delete` |
| `mkdir(path: string)` | `files:write` | Create a directory (and parents). → `files.mkdir` |
| `move(from: string, to: string)` | `files:write` | Move / rename a file or directory. → `files.move` |
| `download(path: string)` | `files:read` | Short-lived signed URL for a file's bytes. → `files.downloadUrl` |

**`dench.chat`**

| Method | Permission | Notes |
|---|---|---|
| `createSession(title?: string)` | `ai:chat` | Create an empty chat thread; returns its id. |
| `send(sessionId: string, message: string, opts?: { onEvent?: (event: ChatStreamEvent) => void })` | `ai:chat` | Send a message to a thread. With `onEvent`, text-delta chunks stream in; resolves with the full reply. |
| `getHistory(sessionId: string, opts?: { limit?: number })` | `ai:chat` | Messages in a thread (via chat.read). → `chat.read` |
| `getSessions(opts?: chat.list args)` | `ai:chat` | Recent threads. → `chat.list` |
| `abort(sessionId: string)` | `ai:chat` | Stop the active run on a thread. |
| `isActive(sessionId: string)` | `ai:chat` | Whether the thread has a running turn. |
| `list(opts?: chat.list args)` | `ai:chat` | Recent threads (alias of getSessions). → `chat.list` |
| `search(query: string, opts?: chat.search args)` | `ai:chat` | Full-text search across past messages. → `chat.search` |
| `rename(threadId: string, title: string)` | `ai:chat` | Rename a thread. → `chat.rename` |
| `delete(threadIds: string \| string[])` | `ai:chat` | Delete one or more threads. → `chat.delete` |

**`dench.agent`**

| Method | Permission | Notes |
|---|---|---|
| `send(message: string, opts?: { title?: string; model?: string; yolo?: boolean })` | `agent:invoke` | Start a new agent turn in a fresh thread. Resolves once the run is accepted. |

**`dench.tool`**

| Method | Permission | Notes |
|---|---|---|
| `register(name: string, handler: (args: unknown) => unknown \| Promise<unknown>, opts?: { description?: string; inputSchema?: unknown })` | `agent:invoke` | Expose a function the workspace agent can call while this app is open. |

**`dench.ui`**

| Method | Permission | Notes |
|---|---|---|
| `toast(message: string, opts?: { type?: "success" \| "error" \| "info" })` | `ui` | Show a toast. |
| `navigate(path: string)` | `ui` | Navigate the workspace to a path (e.g. /objects/company). |
| `openEntry(objectName: string, entryId: string)` | `ui` | Open a CRM record in the workspace. |
| `setTitle(title: string)` | `ui` | Set the app tab title. |
| `confirm(message: string)` | `ui` | Native confirm dialog. |
| `prompt(message: string, defaultValue?: string)` | `ui` | Native prompt dialog. |

**`dench.store`**

| Method | Permission | Notes |
|---|---|---|
| `get(key: string)` | `store:read` | Read a per-app value. |
| `set(key: string, value: unknown)` | `store:write` | Write a per-app value (JSON). |
| `delete(key: string)` | `store:write` | Delete a key. |
| `list()` | `store:read` | All keys for this app. |
| `clear()` | `store:write` | Delete every key. |

**`dench.http`**

| Method | Permission | Notes |
|---|---|---|
| `fetch(url: string, opts?: { method?: string; headers?: Record<string, string>; body?: unknown })` | `http:external` | Fetch an external URL through the SSRF-guarded proxy. |

**`dench.events`**

| Method | Permission | Notes |
|---|---|---|
| `on(channel: string, callback: (data: unknown) => void)` | — | Subscribe to a channel ("object.entry.created" \| "object.entry.updated" \| "object.entry.deleted" \| "theme.changed"). |
| `off(channel: string, callback?: (data: unknown) => void)` | — | Unsubscribe. |

**`dench.bus`**

| Method | Permission | Notes |
|---|---|---|
| `send(targetApp: string, message: unknown)` | `apps:message` | Send a message to another open app. |
| `on(callback: (message: { from: string; message: unknown }) => void)` | `apps:message` | Receive messages from other apps. |
| `list()` | `apps:message` | Other apps currently open. |

**`dench.apps`**

| Method | Permission | Notes |
|---|---|---|
| `list()` | `apps:read` | Installed apps in this workspace. → `apps.list` |
| `gallery()` | `apps:read` | Discover listings (Official + Community). → `apps.gallery` |
| `create(opts: apps.create args)` | `apps:write` | Scaffold and register a new app. → `apps.create` |
| `install(listingId: string)` | `apps:write` | Install a listing. → `apps.install` |
| `publish(opts: apps.publish args)` | `apps:write` | Publish an app folder as a listing version. → `apps.publish` |
| `uninstall(slug: string)` | `apps:write` | Uninstall an app. → `apps.uninstall` |
| `enable(slug: string)` | `apps:write` | Enable an installed app. → `apps.setEnabled` |
| `disable(slug: string)` | `apps:write` | Disable an installed app. → `apps.setEnabled` |
| `pin(slug: string)` | `apps:write` | Pin an app in the rail. → `apps.setPinned` |
| `unpin(slug: string)` | `apps:write` | Unpin an app. → `apps.setPinned` |
| `send(targetApp: string, message: unknown)` | `apps:message` | Deprecated alias of bus.send. **Deprecated:** Use dench.bus.send. |
| `on(eventType: "message", callback: (message: { from: string; message: unknown }) => void)` | `apps:message` | Deprecated alias of bus.on. **Deprecated:** Use dench.bus.on. |

**`dench.cron`**

| Method | Permission | Notes |
|---|---|---|
| `schedule(opts: CronScheduleOptions)` | `cron:schedule` | Create a scheduled agent (automation). |
| `list()` | `cron:schedule` | Automations in the workspace. |
| `update(jobId: string, patch: Partial<CronScheduleOptions>)` | `cron:schedule` | Update an automation. |
| `run(jobId: string)` | `cron:schedule` | Run an automation now. |
| `cancel(jobId: string)` | `cron:schedule` | Delete an automation. |

**`dench.webhooks`**

| Method | Permission | Notes |
|---|---|---|
| `register(hookName: string)` | `webhooks:receive` | Register an inbound webhook for this app. Returns the URL and signing secret ONCE — store the secret with the sender. |
| `on(hookName: string, callback: (event: AppWebhookEvent) => void)` | `webhooks:receive` | Receive events for a hook while the app is open (polls every 5s). |
| `poll(hookName: string, opts?: { since?: number; limit?: number })` | `webhooks:receive` | Events received since a timestamp. |
| `list()` | `webhooks:receive` | This app's registered hooks (URLs, never secrets). |
| `delete(hookName: string)` | `webhooks:receive` | Remove a hook and its stored events. |

**`dench.clipboard`**

| Method | Permission | Notes |
|---|---|---|
| `read()` | `clipboard:read` | Read clipboard text. |
| `write(text: string)` | `clipboard:write` | Write clipboard text. |

**`dench.integrations`**

| Method | Permission | Notes |
|---|---|---|
| `list()` | `integrations:read` | Connected accounts (toolkit, status, connectedAccountId). |
| `search(opts: { query?: string; toolkit?: string; limit?: number })` | `integrations:read` | Find tools by natural-language query and/or toolkit. |
| `execute(opts: { tool: string; arguments?: Record<string, unknown>; connectedAccountId?: string })` | `integrations:read` | Run a tool. Write-classified tools additionally need integrations:write. |

**`dench.geo`**

| Method | Permission | Notes |
|---|---|---|
| `resolveLocation(text: string)` | — | Free text → country (ISO2 + ISO numeric). Same rules as the analytics map. |
| `resolveRegion(text: string, iso2: string)` | — | Free text → admin-1 region (state/province) within a country. Loads that country's file on first use. |
| `loadAdmin1(iso2: string)` | — | First-level divisions of a country as GeoJSON features (what the map draws). |

**`dench.db`**

| Method | Permission | Notes |
|---|---|---|
| `query(sql: string)` | `crm:read` | Removed. DuckDB SQL is gone — use objects.query / analytics.compute. **Deprecated:** Removed: use objects.query or analytics.compute. |
| `execute(sql: string)` | `crm:write` | Removed. Use objects.create / update / delete. **Deprecated:** Removed: use objects.create / update / delete. |
<!-- app-sdk:index:end -->

## Official examples

Shipped in the gallery and kept working against this contract
(`convex/lib/seedApps/`):

- **Pipeline Health** (`crm-analytics`) — `analytics.get` + `compute("deal",
  { period })` for stored stats and a donut, one ad-hoc leaderboard with
  `ui.openEntry`, `previousValue` deltas, the `truncated` badge, and a map
  drawn from `compute` buckets placed through `dench.geo`.
- **Inbox** (`inbox`) — `notifications.list/read/archive` + `comments.list`
  on the mentioned record, `ui.openEntry` to jump in.
- **Meeting Brief** (`meetings-brief`) — `meetings.list`, `transcript`,
  `actionItems`, `setActionItemDone`.
- **Dench CMO** (`agents-feed`) — cron + chat + integrations, bridge-only.

## Design philosophy (strict)

Dench Apps must feel like polished, native workspace surfaces — closer to a
well-crafted iOS app than a flat embedded widget. The host already owns the
outer chrome, tab, inset, and rounded iframe frame; your app owns the inside
surface only. Do not add a second title bar, logo bar, browser frame, floating
theme toggle, or heavy app shell.

Aim for **striking but tasteful**: one vibrant accent, a real gradient moment,
crisp typography, without noise.

- **Anchor on one vibrant accent.** Default to Dench denim (`#206199` light /
  `#5b9fd8` dark) or choose one per category. Derive every tint, wash, border
  glow, ring, and gradient from it with `color-mix()`.
- **Gradients with intent.** Lead with a gradient hero/header band carrying the
  app title, a short tagline, and an icon tile. One confident gradient.
- **iOS-style icon tiles.** Rounded-square (`border-radius` ≈ 22%), accent
  gradient, white **inline SVG** glyph (no icon fonts, no external images).
- **Confident typography.** 18–24px semibold headings, `letter-spacing:
  -0.01em`, Inter/system stack; muted sentence-case secondary text.
- **Clean cards, soft depth.** 1px hairlines, 12–16px radii, generous padding,
  accent-tinted hover.
- **Accent-tinted KPI tiles** with the big value in the accent color.
- **Polished states.** Real loading (accent spinner/skeleton), empty, error.
- **Full dark mode, theme-synced** via `dench.app.getTheme()` +
  `theme.changed` — never `localStorage` / `prefers-color-scheme`.
- **Respect the iframe edge.** Root `min-height: 100vh`, internal padding
  16–24px, no outer margins. No external fonts, CSS, JS, images, or CDNs (CSP
  blocks them) — inline everything.

Start every stylesheet from these tokens:

```css
:root {
  color-scheme: light;
  --accent: #206199;                                  /* one accent drives everything */
  --accent-strong: color-mix(in srgb, var(--accent) 68%, #000);
  --on-accent: #ffffff;
  --bg: #fafaf9; --surface: #ffffff; --surface-2: #f5f5f4;
  --text: #171717; --muted: #6b7280;
  --border: rgba(28, 28, 26, 0.1); --border-strong: rgba(28, 28, 26, 0.16);
  --tint: color-mix(in srgb, var(--accent) 9%, var(--surface));
  --tint-strong: color-mix(in srgb, var(--accent) 16%, var(--surface));
  --hover: color-mix(in srgb, var(--accent) 6%, transparent);
  --ring: color-mix(in srgb, var(--accent) 30%, transparent);
  --hero-grad: linear-gradient(145deg, var(--accent), color-mix(in srgb, var(--accent) 65%, #000));
  --tile-grad: linear-gradient(145deg, var(--accent), color-mix(in srgb, var(--accent) 68%, #000));
  --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05);
  --shadow-lg: 0 18px 40px -24px color-mix(in srgb, var(--accent) 55%, #000);
  --radius: 16px;
  --heat-lo: #a3e635; --heat-hi: #ef4444;             /* the strip's lime → red ramp */
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --accent: #5b9fd8; --on-accent: #0b1220;
  --bg: #101010; --surface: #171717; --surface-2: #1f1f1f;
  --text: #f5f5f5; --muted: #a3a3a3;
  --border: rgba(255, 255, 255, 0.12); --border-strong: rgba(255, 255, 255, 0.2);
  --tint: color-mix(in srgb, var(--accent) 14%, var(--surface));
  --tint-strong: color-mix(in srgb, var(--accent) 22%, var(--surface));
  --hover: color-mix(in srgb, var(--accent) 12%, transparent);
  --ring: color-mix(in srgb, var(--accent) 40%, transparent);
  --hero-grad: linear-gradient(145deg, color-mix(in srgb, var(--accent) 80%, #000), color-mix(in srgb, var(--accent) 45%, #000));
  --shadow-lg: 0 18px 44px -24px rgba(0, 0, 0, 0.7);
}
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; background: var(--bg); color: var(--text);
  font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.icon-tile { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px;
  border-radius: 22%; background: var(--tile-grad); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,.18); }
.hero { position: relative; overflow: hidden; border-radius: 20px; padding: 22px 24px;
  background: var(--hero-grad); color: #fff; box-shadow: var(--shadow-lg); }
.hero::after { content: ""; position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(120% 140% at 100% 0%, rgba(255,255,255,.22), transparent 55%); }
.hero .eyebrow { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; opacity: .85; }
.hero h1 { margin: 6px 0 0; font-size: 22px; font-weight: 600; letter-spacing: -.01em; }
.card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow-sm);
  transition: border-color .15s ease, box-shadow .15s ease; }
.card:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); box-shadow: var(--shadow-lg); }
.stat { border: 1px solid var(--border); border-radius: 14px; background: var(--tint); padding: 14px 16px; }
.stat .value { font-size: 24px; font-weight: 600; letter-spacing: -.01em; color: var(--accent); }
.primary-button { border: 0; border-radius: 10px; padding: 9px 14px; font-weight: 600; background: var(--accent); color: var(--on-accent); cursor: pointer; }
.primary-button:hover { background: var(--accent-strong); }
.spinner { width: 22px; height: 22px; border-radius: 50%; border: 2.5px solid var(--tint-strong); border-top-color: var(--accent); animation: dench-spin .7s linear infinite; }
@keyframes dench-spin { to { transform: rotate(360deg); } }
```

## Theme sync (required)

```html
<script>
  function applyDenchTheme(theme) {
    const resolved = theme === "dark" ? "dark" : "light";
    document.documentElement.dataset.theme = resolved;
    document.documentElement.style.colorScheme = resolved;
  }
  async function syncDenchTheme() {
    try {
      applyDenchTheme(await window.dench.app.getTheme());
      window.dench.events.on("theme.changed", ({ theme }) => applyDenchTheme(theme));
    } catch { applyDenchTheme("light"); }
  }
  syncDenchTheme();
</script>
```

> **Removed:** `dench.db.query(sql)` / `dench.db.execute(sql)` (DuckDB era)
> now reject with `deprecated`. Use `objects.query`, `analytics.compute`, or
> `objects.create/update/delete`.

## Runtime & security

- Apps render in a sandboxed iframe (`allow-scripts allow-popups allow-forms`,
  currently same-origin; a separate origin is the hardening target — the
  bridge is the only contract that survives that move). `postMessage` is the
  only privileged channel; the host checks the manifest's permissions on
  every request, and every product call goes through `POST /api/apps/sdk`,
  the same dispatcher as `/api/v1`.
- `<a href>` opens a new tab (`<base target="_blank">` is injected). Use
  `ui.navigate` / `ui.openEntry` for in-workspace navigation.
- `http.fetch` is proxied with an SSRF guard.
- Apps are per-org, derived from the org's filesystem (`/workspace/apps`).

## Create / install / publish (lifecycle)

```bash
dench apps create pipeline-health --name "Pipeline Health" --icon bar-chart
dench apps validate /apps/pipeline-health.dench.app   # manifest + every dench.* call
dench apps list | gallery | info <slug>
dench apps publish /apps/pipeline-health.dench.app --slug pipeline-health --name "Pipeline Health" [--visibility org|public|official]
dench apps install <listingId>
dench apps uninstall | enable | disable | pin | unpin <slug>
dench apps webhooks register|list|poll|delete <slug> [hookName]
```

Same surface over REST (`Authorization: Bearer <DENCH_API_KEY>`): `GET|POST
/api/v1/apps`, `GET /api/v1/apps/gallery`, `POST /api/v1/apps/publish`,
`POST /api/v1/apps/install`, `DELETE|PATCH /api/v1/apps/{slug}`, `POST
/api/v1/apps/{slug}/pin`, `POST|GET /api/v1/apps/{slug}/webhooks`.

Optional manifest keys: `template: <id>` installs a companion CRM template
with an official listing; `display: widget` + `widget: { width, height,
refreshInterval }` renders the app as a dashboard card.

**In-chat widgets.** For a chart the panel model cannot express, the agent
writes ONE self-contained HTML file (≤ 40 KB) and calls
`present_artifact({ kind: "app", title, html, permissions: ["crm:read", "ui"] })`
— it renders in the thread with this same bridge. "Keep as app" copies it to
`/apps/<slug>.dench.app`.

## Related

- CRM objects, fields, analytics model: [`references/CRM.md`](https://dench.com/references/CRM.md)
- Every operation with curl: [`references/API.md`](https://dench.com/references/API.md)
- Deep links for `ui.navigate`: [`references/NAVIGATION.md`](https://dench.com/references/NAVIGATION.md)
- Action buttons on CRM rows: [`references/ACTIONS.md`](https://dench.com/references/ACTIONS.md)
