# AI App runtime SDK

A Wink AI App is a single React module that runs inside Wink's sandboxed iframe
on a dashboard. The host injects data and tool APIs. The app must not call
invented HTTP endpoints, embed tokens, or talk to MCP.

This document is the contract for agents that write that module. You write the
code; Wink hosts it.

Read this before writing `App.jsx`. Also read `wink-toolbox://docs/wsql` and
`wink-toolbox://docs/ai-tools`.

## What an AI App is

- One React module with `export default` of a function component
- One primary WSQL query, delivered to the app as `useHostData('rows', { version: '1.2' })`
- Optional on-demand WSQL via the allow-listed tool `gspread_reports_execute_query`
  (same action as MCP `run_wsql_query`)
- Optional writes via `executeHostTool` (same actions as MCP `run_ai_tool`)
- Tailwind CSS and an import map (React, `react-dom/client`, optional npm packages via
  `esm.sh`)

It is not a separately hosted website. It has no Node, no local filesystem, and no
cookies.

## Host-injected APIs

The host injects these globals. Import them as if they already exist; do not redefine
them and do not call `window.postMessage` yourself.

### `useHostData(resource, options)`

```js
const BRIDGE_VERSION = '1.2';
const { data, loading, refetch } = useHostData('rows', { version: BRIDGE_VERSION });
const { data: hostContext } = useHostData('context', { version: BRIDGE_VERSION });
```

- `options.version` is **required** and must be the literal string `'1.2'`. Without it
  the hook logs a warning and never loads data.
- `resource` is `'rows'` for the primary query, or `'context'` for live tenant / viewer
  identity (organisation branding and optional current user).
- Returns `{ data, loading, refetch }`. Call `refetch()` after a successful write if
  the table should refresh.

When `resource` is `'rows'` and `version` is `'1.2'`, `data` is an object (not a bare array):

```ts
{
  rows: Array<Record<string, unknown>>;
  columns: Record<string, string>; // column name → type name (Decimal, Date, ...)
  previousPeriodRows: Array<Record<string, unknown>>;
  dateComparisonMap: Array<{ currentDate: string; previousDate: string | null }>;
  compareDateKey: string | null;
}
```

Row keys are the WSQL column names (human-friendly Title Case, including spaces).

When `resource` is `'context'`, `data` is:

```ts
{
  organisation: {
    id: string;
    name: string;
    logoUrl: string | null;
  };
  user: {
    name: string;
    email: string;
    avatarUrl: string | null;
    timezone: string | null;
    locale: string | null; // e.g. en_AU
  } | null; // null for anonymous shared / preview viewers
}
```

Organisation is always the tenant that owns the dashboard or preview. Do **not** hardcode
organisation name, logo URLs, or viewer name/email/avatar into the module — read them from
`useHostData('context')` so they stay correct when branding or the viewer changes.
When `user` is present, format dates and numbers with `user.timezone` and `user.locale`
when those fields are non-null.

**Preview vs dashboard:** a preview does not apply dashboard filter values, so row
counts can differ from the dashboard. It also has no compare period:
`previousPeriodRows` and `dateComparisonMap` stay empty until the app is on a
dashboard that has comparison configured. Preview and anonymous shared viewers have
`user: null`; organisation branding is still provided.

**Compare periods:** Draw a previous-period series only when the user asked (compare,
vs, previous period, year-over-year). Aggregate `rows` and `previousPeriodRows`
separately, then align dates with `dateComparisonMap`. `previousDate` may be null.
Treat `0` as a real previous value.

Handle loading and empty states. Do not render as if `data` is always present.

### `executeHostTool(actionName, inputs, { version })`

```js
const result = await executeHostTool(
  'some_action_name',
  { Field: 'value' },
  { version: '1.2' },
);
```

- `version` is required (`'1.2'`).
- `actionName` must be in the app's `requested_tools` **and** on the caller's agent
  token. Discover names with MCP `list_available_ai_tools` / `get_ai_tool_details`.
  Never invent names.
- `inputs` is a plain object matching the tool schema.
- With version `'1.2'`, the promise resolves to the tool's `result` payload (not the
  full envelope). Use `executeHostToolDetailed` if you need `{ executionUuid, result,
  logs, actionCount }`.

This is the in-app equivalent of MCP `run_ai_tool`. Writes are real: preview and
dashboard both run against live organisation data with the token owner's permissions.

### `openUrl(url)`

Opens `http:` / `https:` URLs in a new tab. Do not use it for Wink admin URLs, MCP
endpoints, or `javascript:` links.

## Module rules

- Single file. `export default` a React function component. No nested component
  declarations; extract helpers as functions, not components.
- No local path imports (`./foo`, `../bar`). Bare package names only, listed in the
  import map.
- Default import map already includes `react`, `react/jsx-runtime`, `react-dom`,
  `react-dom/client`. Extra packages go through `esm.sh` (for example
  `https://esm.sh/lucide-react@0.460.0`). Tailwind is already loaded; do not import a
  CSS framework.
- Do not import the host APIs; they are injected. Do not use `window.useHostData` or
  raw `postMessage`.
- Do not `fetch()` Wink, MCP, database, or invented REST URLs. Data comes from
  `useHostData` or `executeHostTool`.
- Prefer Tailwind utility classes unless the user asks for something else. Tailwind is
  already loaded; do not import another CSS framework. Avoid CSS modules,
  styled-components, and `<style>` tags unless requested.
- Keep the widget visually quiet: modest type, padding, rounded corners, light borders.
  Optional `lucide-react` for headers or metrics (add it to the import map if used).
- Stable list keys. No widget title in the module (the dashboard chrome owns the title).
- Keep the module small. The host compiles it with Babel (`react` preset) in the iframe.

## Primary WSQL

The app's primary query is stored with the app (MCP `wsql` / `wsql_options` / `limit` on
create and update). The host runs it and feeds object rows into `useHostData('rows')`.

- Discover datasources with `list_data_sources` / `get_data_source_detail`.
- Validate **and execute** with `run_wsql_query` before you bind UI to a shape. You
  need to have seen real rows, not only a successful validate.
- Do not invent slugs or column names. Preserve exact human-friendly column casing.
- If the app has no WSQL, do not call `useHostData('rows')`. `useHostData('context')`
  is still valid.

## Host filters

Datasource parameters (from `get_data_source_detail`) are host filters. Wink applies
them to each embedded datasource before the SQL `WHERE` runs. They are not a `WHERE`
clause, and they are not the `parameters` object on `run_wsql_query`.

`run_wsql_query` `parameters` only tests a query with explicit values. It does not
save filter settings. Saved settings are `wsql_options` on `create_ai_app` /
`update_ai_app`.

Copy filter names from that datasource's parameters. Do not invent them. Key each
entry by its SQL alias, or by the datasource slug when the query has no alias.

```json
{
  "aliases": {
    "inv": {
      "enabledFilters": ["status", "customer"],
      "multiOrg": false,
      "dateField": "Invoice Date"
    }
  }
}
```

- Omit `wsql_options`, or omit `enabledFilters` on an alias, and every parameter for
  that alias stays on.
- `enabledFilters: []` turns that alias's filters off. A list keeps only those names.
- `date_range` defaults to **Last 7 Days**. A fixed window (this month, last 90 days,
  all time) belongs in the SQL, and `date_range` must be left out of `enabledFilters`.
  A user-adjustable range keeps `date_range` and does not also hardcode the window in
  SQL.
- `dateField` is the date column title `date_range` uses. Copy it from the
  datasource's date parameters.
- `multiOrg: false` is this organisation only. Omit it, or set `true`, to include
  child organisations when the datasource supports it. That default is the opposite
  of `include_child_organisations` on `run_wsql_query` and the datasource tools,
  which stays off unless you pass it.
- Joined datasources should use `enabledFilters: []` unless the user wants those
  filters. A date filter on the joined table drops rows before the join.

The dashboard filter chrome owns those controls. `useHostData('rows')` is already
filtered. Do not rebuild date range, status, or customer pickers in the module.
In-app chips are only for slicing rows the host already returned. Empty or sparse
dashboard rows can be active filters. A preview does not apply dashboard filter
values, so its counts can differ from the dashboard.

## On-demand WSQL

The primary query stays on the app's saved `wsql` and arrives through
`useHostData('rows')`. Extra or drilldown queries use
`executeHostTool('gspread_reports_execute_query', …)` when that tool is on the
token and listed in `requested_tools`.

Confirm the schema with `get_ai_tool_details` before writing the call. `parameters`
must be a JSON string, not an object. The row payload is a JSON string; `JSON.parse`
it. Write the SQL with `run_wsql_query` (`validate`, then `execute`) and embed that
string. Do not invent rows if the call fails.

```js
const output = await executeHostTool(
  'gspread_reports_execute_query',
  {
    wsql_query: savedWsqlString,
    parameters: JSON.stringify({ date_range: 'This Month' }),
  },
  { version: '1.2' },
);
const rows = JSON.parse(output['Query Result JSON']);
```

This is a live query with the token owner's access.

**Preview URLs are live-data capabilities.** Anyone with the URL can run the snapshot
query and the app's requested tools as the token owner until the session expires. If
`gspread_reports_execute_query` is requested, the URL can run additional WSQL for that
TTL. Treat preview links as secrets.

## Suggested on-disk shape (documentation only)

This MCP does not write files. A convenient local layout:

```text
App.jsx          # export default function App() { ... }
query.wsql       # primary WSQL
wink-app.json    # app_id, revision, title, requested_tools, import_map
```

- `create_ai_app` is not idempotent. After create, write `app_id` and `revision` into
  `wink-app.json`.
- `update_ai_app` requires `expected_revision`. A 409 means someone else (or another
  session) updated the remote; pull with `get_ai_app` rather than overwriting.
- `list_ai_apps` omits source code. Call `get_ai_app` when you need the module.
- If create times out before you see `app_id`, call `list_ai_apps`. Do not create a
  second app.

## Workflow for coding harnesses

1. `list_data_sources` → `get_data_source_detail` → write and `run_wsql_query`
   (`validate`, then `execute`) until you have seen real rows and columns.
2. `list_available_ai_tools` → `get_ai_tool_details` for any writes. Put those names in
   `requested_tools`. Names not on the token are rejected.
3. Write one `App.jsx` that uses `useHostData('rows', { version: '1.2' })` and
   `executeHostTool(..., { version: '1.2' })`.
4. `create_ai_app` or `update_ai_app` (with `expected_revision`). Pass `wsql_options`
   when host filters should differ from the defaults (see Host filters).
5. **Always preview before publishing.** `create_preview_session`, then open the URL
   in a browser (use a browser tool if you have one). `get_preview_status`:
   `pending` means the tab has not been opened; `error` means fix the module and
   preview again. Compile and runtime errors are common; do not skip this step.
6. Publish only to a **dashboard** (`list_dashboards` / `create_dashboard`, then
   `publish_ai_app`). Tell the user `dashboard_url` and `item_url` from the result
   so they can open the dashboard and the widget.

## Editing a widget that already exists

`get_dashboard` lists items with `is_generative`. `clone_ai_app` copies that widget
into a draft. When nobody else has a live draft on it, the draft is linked to that
dashboard item.

- `created: false` means this token owner already links that widget. Use that app.
  Do not clone again, and do not overwrite the draft with the live widget.
- Empty `placements` means a private copy: someone else already links the widget.
  Publishing that copy to the same dashboard adds a new widget and does not update
  the existing one. Cloning again while that other link exists creates another draft.
- Edit with `update_ai_app`, preview, then `publish_ai_app`. A linked draft updates
  that widget. Another dashboard adds a new widget and leaves the original link in
  place.
- If the placement is `diverged`, someone edited the widget in Wink after the link
  was made. Do not `publish_ai_app` with `force` unless the human asked after seeing
  that message.

`list_dashboard_item_versions` and `get_dashboard_item_version` read the widget's
history. `rollback_dashboard_item` restores one row and writes a new history entry.
Rollback changes the widget only. It does not rewrite the draft, so the placement
becomes diverged and the next publish conflicts until the human confirms.

## What this MCP will not do

- It will not generate the React module for you (no `generate_ai_app`).
- `validate_ai_app` is contract linting only. Compile and runtime errors show up in
  the browser preview (`WIDGET_ERROR` with `phase: 'compile'` after the URL is opened).
- It will not silently overwrite a remote app. Stale `expected_revision` is a conflict.
