# Add a connector
Source: https://docs.tappify.ai/extensions/build/add-a-connector
Pull your own metrics into Tappify on a schedule and draw them on Tappify's charts.
A connector is a schedule and a list of metrics. Tappify calls your server on the cadence you
declare, stores the numbers it gets back, and draws them beside its own on the chart you name.
```bash theme={null}
tappify extension add connector --sync 1h
```
Leave the flag off and the command asks how often Tappify should pull. It writes the connector
with one example metric and a `server/metrics.ts` to fill in; the metric list, its units and its
breakdowns are yours to edit afterwards.
## The manifest entry
```json theme={null}
{
"scopes": [{ "key": "ui:render" }, { "key": "metrics:write" }],
"contributes": {
"connector": {
"sync": "1h",
"metrics": [
{
"key": "active_users",
"label": "Active users",
"unit": "count",
"kind": "gauge",
"dimensions": [{ "key": "channel", "label": "Channel" }],
"backfillDays": 30
}
]
},
"series": [
{
"id": "active-users",
"label": "Active users",
"chart": "installs-activity",
"metric": "active_users"
}
]
},
"server": { "baseUrl": "https://funnel-lab.dev" }
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
| Field | Means |
| -------------- | ------------------------------------------------------------------------------------------------------------------- |
| `sync` | How often Tappify calls you: `5m`, `15m`, `1h`, `6h` or `24h`. The command offers the last four |
| `key` | The name you return in the response, `snake_case`. Points are stored under it, so renaming one starts a new history |
| `unit` | `count`, `ratio`, `currency`, `seconds` or `bytes`. The host formats by this |
| `kind` | `gauge` reports a level, `counter` reports how many happened in the bucket |
| `dimensions` | Breakdowns the host offers owners for this metric, next to its own. At most 10 |
| `backfillDays` | How far back your first sync may reach. At most 90 |
Declaring metrics needs the `metrics:write` scope. At most 50 metrics.
A `series` contribution is what puts the metric on a chart. Its `metric` must be one you declare
here, and its `chart` is one of the host charts in
[Pages and slots](/extensions/reference/pages-and-slots).
## The handler
`createTappifyHandler` routes `POST /tappify/metrics` to your `metrics` handler. The request
carries the window Tappify wants and the metric keys it is asking about.
```ts theme={null}
import { createTappifyHandler } from "@tappify/extension-sdk/server";
export default createTappifyHandler({
extensionId: "funnel-lab",
async metrics(request) {
const { projectId, platform, metrics, from, to } = request.input;
const rows = await countActiveUsers({ projectId, platform, from, to });
return {
series: [
{
metric: "active_users",
unit: "count",
dimensions: { channel: "organic" },
points: rows.map((row) => [row.hour, row.value]),
},
],
};
},
});
```
A point is `[ISO timestamp, number]`, and Tappify truncates each one to the hour it falls in
before storing it, so two points in the same hour under the same breakdown are one row. Return
one entry per breakdown: two `dimensions` values means two entries, each with its own `points`.
The window is not always your full backfill. A metric that already has points is asked for from
its newest bucket onward; one with none is asked for its declared `backfillDays`, and the whole
window is clamped to ninety days.
## What Tappify checks on every sync
| Check | What fails |
| -------------------------------------------------------------- | ------------------------------------------ |
| The response is `{ "series": [...] }` | Anything else |
| Every `metric` is one you declared | An undeclared key |
| Every `unit` matches its declaration | A unit that disagrees |
| Every point is `[ISO timestamp, number]` | A string value, a missing timestamp, `NaN` |
| Every `dimensions` key is declared on that metric | A breakdown the manifest does not name |
| A `counter` never reports a negative | A negative in a counter |
| No point is older than `backfillDays` or later than the window | A point outside the window |
A response that fails any of these writes no points and the run is marked failed, with the first
three problems named on the run. Owners read the failed run, its attempts and your status code on
the extension's page in their workspace; your Runtime page counts the installs whose last sync
could not reach you.
## Retries, and when a metric goes stale
A failed pull is retried three times, waiting 30 seconds, then 2 minutes, then 10 minutes. All
four attempts are one run, so the run carries the attempt count; after the last one the run is
failed and the next scheduled sync starts fresh.
A metric with no successful sync in 24 hours is stale. Its points stay and the line is still
drawn, dashed, with `Last synced 3 days ago` beside it in the chart legend and the same reading
on the install's sync health panel.
## Owner credentials
When your metrics come from a service the owner pays for, declare what you need and Tappify
collects it on the install screen, stores it, and sends it with every call.
```json theme={null}
{
"contributes": {
"connector": {
"sync": "6h",
"auth": {
"method": "api_key",
"fields": [
{
"name": "api_key",
"label": "API key",
"type": "password",
"required": true,
"help": "Settings → Developers → API keys"
}
]
},
"metrics": [
{ "key": "sessions", "label": "Sessions", "unit": "count", "kind": "counter" }
]
}
}
}
```
The values arrive as `request.credentials` on every call Tappify makes to you, keyed by each
field's `name`. They are never sent to a browser and never sent to anyone else. A field is
`text`, `password`, `textarea`, `file` or `select`; at most 20 of them.
For `"method": "oauth"`, add an `auth.oauth` block with `authorizationUrl`, `tokenUrl` and
`scopes` — that service's scopes, not Tappify's — plus fields named `client_id` and
`client_secret`. The owner registers your extension with their own provider, pastes those in, and
presses Connect on the extension's page.
The provider sends the owner back to Tappify, not to you, so the owner has to register this
redirect URI with the provider:
```
https://api.tappify.ai/api/v1/extensions/oauth/callback
```
Without it the provider refuses the handshake with `redirect_uri_mismatch`. Tappify shows the
address beside the Connect button on the install's page, and on your OAuth client page in the
portal so you can put it in your own instructions. On staging the host is the staging API origin
rather than `api.tappify.ai`; the path is the same.
## Health
Tappify calls `GET /tappify/health` every fifteen minutes and at every publish. Answer 200 within
three seconds. The last 90 days of those answers are the uptime number on your listing, and under
99.5 % the listing is flagged.
```ts theme={null}
export default createTappifyHandler({
extensionId: "funnel-lab",
health: () => ({ ok: true }),
async metrics(request) {
/* … */
},
});
```
# Add a page
Source: https://docs.tappify.ai/extensions/build/add-a-page
Give your extension its own route under the owner's project, with optional navigation entry.
A page is your own route inside the owner's project, at
`/projects/:projectId/ext/:extensionId/:pageId`. With `nav: true` it also gets an entry in
the project navigation, with your vendor tile beside the name.
```bash theme={null}
tappify extension add page --name Explore --nav
```
## The manifest entry
```json theme={null}
{
"contributes": {
"pages": [
{
"id": "explore",
"title": "Explore",
"entry": "src/pages/explore.tsx",
"nav": true
}
]
}
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
## The component
`TapPageProps` carries `params`, the segments under your own page's path, keyed by position
alongside `pageId` and the unsplit `path`.
```tsx theme={null}
import {
TapPageHeader,
useTapParams,
type TapPageProps,
} from "@tappify/extension-sdk";
import "@tappify/extension-sdk/styles.css";
export default function Explore(_props: TapPageProps) {
const params = useTapParams();
const funnelId = params["0"] === "funnels" ? params["1"] : undefined;
return (
);
}
```
Sub-paths under your page are yours: `tap.nav.push("/explore/funnels/123")` navigates
inside it and `useTapParams()` reads the segments back. See
[Navigate inside your page](/extensions/build/navigate-inside-your-page).
## What the host renders
Tappify draws the project chrome — sidebar, header, breadcrumb — and mounts your component in
the content area with `tap.ui.size` set to `page`. The navigation entry shows your vendor
tile so an owner always knows whose page they are on.
An organization-scoped extension cannot declare pages, tabs, widgets or series: those are
project surfaces. Declare `installScope: "project"` if you need them.
# Add a row action
Source: https://docs.tappify.ai/extensions/build/add-a-row-action
Put an entry in a host table's row menu that opens your component with that row.
A row action adds an entry to the row menu of one of Tappify's tables. Choosing it opens
your component in the expand panel with the row as `tap.context`.
```bash theme={null}
tappify extension add row-action --name "Explain rank" --table analytics.keywords
```
## The manifest entry
```json theme={null}
{
"contributes": {
"rowActions": [
{
"id": "explain-rank",
"title": "Explain rank",
"entry": "src/row-actions/explain-rank.tsx",
"table": "analytics.keywords"
}
]
}
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
The four host tables are `analytics.keywords`, `analytics.reviews`,
`deployments.releases` and `deployments.builds`. They are listed with their columns on
[Pages and slots](/extensions/reference/pages-and-slots).
## The component
```tsx theme={null}
import {
TapButton,
TapCard,
useTap,
useTapServer,
type TapRowActionProps,
} from "@tappify/extension-sdk";
import "@tappify/extension-sdk/styles.css";
export default function ExplainRank({ row }: TapRowActionProps) {
const tap = useTap();
const keyword = typeof row.keyword === "string" ? row.keyword : "";
const explanation = useTapServer("explainRank", { keyword });
return (
void tap.ui.copy(explanation.data?.summary ?? "")}
>
Copy
}
>
{explanation.data?.summary}
);
}
```
`row` arrives as `Record`, which is why `keyword` is narrowed before it is
used. `explanation.data` is typed once `tappify extension types` has turned
`server.procedures` into declarations — see
[Generated types](/extensions/build/generated-types).
The same object arrives as `tap.context`, so a component that is both a widget and a row
action can read one place: `useTapContext()`.
## What the host renders
Your entry appears in the row menu below Tappify's own entries, with the vendor tile.
Choosing it opens the expand panel at 640 pixels; Esc closes it, and the footer's
Open in your product is the only exit out of Tappify. The row is passed read-only — a row
action that changes the owner's data does it through an
[action](/extensions/reference/scopes) with an approval card, never by writing directly.
# Add a settings panel
Source: https://docs.tappify.ai/extensions/build/add-a-settings-panel
Collect the owner's configuration from a JSON Schema and receive it on every server call.
A settings panel is the one form owners see for your extension, on the install's page in
their dashboard. Its values persist as a reserved storage document and reach your server as
`documents.settings` on every call.
```bash theme={null}
tappify extension add settings
```
## The manifest entry
```json theme={null}
{
"contributes": {
"settings": {
"entry": "src/settings.tsx",
"schema": { "$ref": "./schemas/settings.json" }
}
}
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
The command also writes `schemas/settings.json` with a single `enabled` boolean in it,
there to be replaced by what owners set. The examples on this page are written
against two properties:
```json theme={null}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Settings",
"type": "object",
"properties": {
"refreshMinutes": {
"type": "number",
"title": "Refresh minutes",
"description": "How often the summary asks the server for new numbers"
},
"showDelta": {
"type": "boolean",
"title": "Show change",
"description": "Show the change against the previous period"
}
},
"required": ["refreshMinutes"],
"additionalProperties": false
}
```
Run `tappify extension types` after every edit to the schema, so the generated `TapSettings`
follows it.
## The component
`TapSettingsProps` gives you the current values and one callback. Render the schema with
`TapForm` rather than writing inputs by hand — it maps the same schema the manifest points
at, so the form and the generated `TapSettings` type cannot disagree.
```tsx theme={null}
import {
TapForm,
TapPageHeader,
type TapSettingsProps,
} from "@tappify/extension-sdk";
import "@tappify/extension-sdk/styles.css";
import schema from "../schemas/settings.json";
export default function Settings({ values, onChange }: TapSettingsProps) {
return (
onChange(next)}
/>
);
}
```
## Where the values go
Saving writes the install-scoped `settings` singleton, which is why `storage` may not
declare a collection of that name. Three things happen at once:
* The document is validated against your schema before it is stored.
* Every mount of your extension in that browser is told, so a widget reflects a saved
preference without a reload.
* A `settings.changed` event fires, which your UI can subscribe to and your server can
receive.
Your server sees the current values as `documents.settings` on every call it receives, so
it never asks Tappify for them.
```ts theme={null}
procedures: {
getSummary: (request) => {
const minutes = request.documents.settings?.refreshMinutes ?? 15;
return { installs: 0, delta: 0, refreshedEvery: minutes };
},
},
```
What `TapForm` renders for each schema construct.
Collections of your own, scoped per teammate, install or workspace.
Render the panel against fixture data before anything is installed.
# Add a tab
Source: https://docs.tappify.ai/extensions/build/add-a-tab
Add a tab of your own to a host page, beside Tappify's.
A tab is a full-width surface that sits in a host page's tab strip, next to Tappify's own
tabs, with your vendor tile on the label.
```bash theme={null}
tappify extension add tab --name Ratings --page analytics
```
## The manifest entry
```json theme={null}
{
"contributes": {
"tabs": [
{
"id": "ratings",
"title": "Ratings",
"entry": "src/tabs/ratings.tsx",
"page": "analytics"
}
]
}
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
A tab validates on `overview`, `analytics` and `deployments`. The host draws it on Analytics
and Deployments today; Home accepts the contribution and does not render it yet — see
[Pages and slots](/extensions/reference/pages-and-slots). `mobile: false` hides the tab on
phone layouts.
## The component
`TapTabProps` is empty — a tab reads everything it needs from the bridge.
```tsx theme={null}
import {
TapPageHeader,
TapTable,
useTapFilters,
useTapQuery,
type TapTabProps,
} from "@tappify/extension-sdk";
import "@tappify/extension-sdk/styles.css";
export default function Ratings(_props: TapTabProps) {
const filters = useTapFilters();
const reviews = useTapQuery({ kind: "reviews", range: filters.range });
return (
row.id}
empty="No reviews in this range."
/>
);
}
```
A `reviews` query needs `reviews:read`, and the result carries `reviews`, `averageRating`
and `total`. See [Scopes](/extensions/reference/scopes).
## What the host renders
The tab strip is Tappify's. Your label carries the vendor tile; selecting it mounts your
component full width with `tap.ui.size` set to `page`. The four boundary states from
[Add a widget](/extensions/build/add-a-widget) apply here too — a tab that fails to load
shows the failure card in the tab body, and the rest of the page is untouched.
A tab always renders at the page's own width, so use container queries against your mount
rather than viewport media queries. The viewport tells you nothing about how wide you are.
# Add a widget
Source: https://docs.tappify.ai/extensions/build/add-a-widget
Render a card in a named slot on a host page, following that page's filters.
A widget is a card that renders in a named slot on one of Tappify's own pages. It must be
readable at 320 pixels, because that is the narrowest slot.
```bash theme={null}
tappify extension add widget --name Funnel --page analytics --slot kpi-row --size 2x1 --expandable
```
Leave the flags off and the command asks in this order: name, host page, slot, size,
expandable.
## The manifest entry
```json theme={null}
{
"contributes": {
"widgets": [
{
"id": "funnel",
"title": "Funnel",
"entry": "src/widgets/funnel.tsx",
"page": "analytics",
"slot": "kpi-row",
"size": "2x1",
"expandable": true
}
]
}
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
| Field | Means |
| ------------ | --------------------------------------------------------------------------------------------------- |
| `page` | Which host page the widget renders on. See [Pages and slots](/extensions/reference/pages-and-slots) |
| `slot` | Which anchor on that page. A slot belongs to one page; the command only offers the ones that fit |
| `size` | `1x1`, `2x1` or `2x2` cells in the slot's grid |
| `expandable` | Whether owners can open the same component in the expand panel |
## The component
The component is the file's default export and receives `TapWidgetProps`.
```tsx theme={null}
import {
TapCard,
TapEmptyState,
TapSkeleton,
TapStat,
useTap,
useTapFilters,
useTapQuery,
type TapWidgetProps,
} from "@tappify/extension-sdk";
import "@tappify/extension-sdk/styles.css";
export default function Funnel(_props: TapWidgetProps) {
const tap = useTap();
const filters = useTapFilters();
const downloads = useTapQuery({
kind: "series",
metric: "downloads",
range: filters.range,
});
if (downloads.isLoading) {
return (
);
}
const points = downloads.data?.points ?? [];
return (
{points.length === 0 ? (
) : (
sum + point.value, 0),
)}
/>
)}
);
}
```
A `series` query needs `analytics:read`; every query kind names its own scope. The command
adds `ui:render`, which every UI contribution requires, and the data scope is one you
declare. See [Scopes](/extensions/reference/scopes).
## What the host renders
The host mounts your component in a shadow root inside the slot and draws the frame around
it: your vendor tile — 16 pixels, your initials — in the card's header, and one of four
states.
| State | What the owner sees |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Loading | A host skeleton until your remote resolves |
| Failed | `Funnel Lab didn't load — remoteEntry.js timed out after 8s. The rest of Tappify is unaffected.` with Retry and Report to vendor |
| Missing scope | `Funnel Lab needs revenue:read on Photo Editor — the current release asks for a scope you haven't granted on this project.` with Review scopes and Keep current |
| Paused | `Funnel Lab is paused. Resume it in Settings → Extensions to see it here.` |
Report to vendor sends the error, the stack, the page and your release checksum to your
[Runtime page](/extensions/publish/reporting-and-quality).
With `expandable: true`, owners can open the same component in a 640-pixel panel. It
re-mounts with `tap.ui.size` set to `panel` and `tap.context` carrying what the surface was
showing. The panel's footer is host-drawn: Ask Tappify about this, Pin as widget, and
Open in your product, which is the only exit.
Why `filters.range` is all the code the number needs to follow the date picker.
The variables that make the card match the page around it.
See the widget while you edit it.
# Add actions
Source: https://docs.tappify.ai/extensions/build/add-actions
Do something on the owner's behalf, behind an approval card they see first.
An action is anything that changes the world outside Tappify. Every one of them creates a run that
starts pending, shows the owner a card, and only reaches your server after they approve.
```bash theme={null}
tappify extension add action send-push --scope messaging:send
```
## The push-notification extension
The example that runs end to end is an extension that sends a push notification through the
owner's own Firebase project. The owner supplies their Firebase credentials at install, the action
asks them before every send, and their credentials reach your server and nowhere else.
```json theme={null}
{
"id": "push-lab",
"name": "Push Lab",
"description": "Send a push notification to a segment of your users.",
"scopes": [
{ "key": "ui:render" },
{
"key": "ai:actions",
"justification": "Sending a notification is a change to the owner's users, so it goes behind an approval card."
},
{
"key": "messaging:send",
"justification": "Sends the notification through the Firebase project the owner connects at install."
}
],
"contributes": {
"connector": {
"auth": {
"method": "api_key",
"fields": [
{
"name": "firebase_project_id",
"label": "Firebase project ID",
"type": "text",
"required": true
},
{
"name": "service_account",
"label": "Service account JSON",
"type": "file",
"required": true,
"help": "Firebase console → Project settings → Service accounts → Generate new private key"
}
]
}
},
"ai": {
"actions": [
{
"id": "send-push",
"description": "Sends one push notification to the audience you pick.",
"input": {
"type": "object",
"properties": {
"audience": { "type": "string", "enum": ["all", "lapsed", "new"] },
"title": { "type": "string" },
"body": { "type": "string" }
},
"required": ["audience", "title", "body"]
},
"approval": true,
"reversible": false,
"scope": "messaging:send",
"estimatesCost": true
}
]
}
},
"server": { "baseUrl": "https://push-lab.dev" }
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
An action `id` is lowercase letters, digits and dashes, and it is the key your handler registers
under. `approval` is always `true`; there is no action that runs without one. `reversible` and
`scope` are what the card tells the owner, and the `scope` has to be one you also declare under
`scopes`. `estimatesCost` is declared only: the manifest accepts it and publish validates it, and
there is no way to supply an estimate, so no run carries a cost and the card shows none. At most
10 actions.
`input` is bounded like a tool's: at most 16 KB serialised, and the `title` and `description`
strings inside it are read by the assistant, scanned at publish, and cut to 400 characters before
it sees them.
## Asking for a run
From your own UI, `tap.actions.run` creates the run and returns it. Tappify draws the card; you
never do.
```tsx theme={null}
import { TapForm, useTap } from "@tappify/extension-sdk";
export default function SendPush() {
const tap = useTap();
return (
{
const run = await tap.actions.run("send-push", values);
tap.ui.toast(
run.status === "pending" ? "Waiting for approval." : "Sent.",
);
}}
/>
);
}
```
The promise resolves as soon as the run exists. A run Tappify refuses to create rejects instead:
with a `TapError` coded `TAP_SCOPE_MISSING` when the install was never granted the action's scope,
and otherwise with a `TapServerError` carrying Tappify's own code — `ACTION_INPUT_INVALID` when
the input fails your schema, `RATE_LIMIT_EXCEEDED` when the project has spent its runs for the
day.
`status` is `pending` whenever the owner still has to approve. An owner can only stop being asked
for an extension whose granted scopes all sit outside security review, and `ai:actions` — which
every action needs — is one that does not, so an action asks every time.
## Handling the run
Your handler is called once, after approval, with the validated input and the owner's credentials.
```ts theme={null}
import { createTappifyHandler } from "@tappify/extension-sdk/server";
export default createTappifyHandler({
extensionId: "push-lab",
actions: {
"send-push": async (request) => {
const { firebase_project_id, service_account } = request.credentials;
const { audience, title, body } = request.input;
const sent = await sendThroughFirebase({
projectId: firebase_project_id,
serviceAccount: JSON.parse(service_account),
audience,
notification: { title, body },
});
return { sent };
},
},
});
```
A 2xx answer marks the run succeeded and whatever object you returned is stored on it. Anything
else marks the run failed and records the status code you answered with, so throw a
`TapServerError` for a failure you want a code and a sentence in your own logs for.
## What the card says
| Line | Where it comes from |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| Title and vendor tile | Your action's id, and your extension's name |
| What it does, and where | Your action's `description` verbatim, then a sentence Tappify writes naming the project and the credentials the owner gave you |
| The input | One row per field of the input your schema validated |
| What it needs | The label of the scope your action declares |
| Whether it can be undone | `Can be undone` or `Cannot be undone`, from your `reversible` |
The card has a slot for an `Estimated cost` line, and it is never drawn: a run's estimate is always
empty, whatever `estimatesCost` says.
Your `description` is the only line you write. Write it as a sentence an owner can act on:
`Sends one push notification to the audience you pick.`, not `Executes the send-push operation.`
## Where a run can start
`tap.actions.run`, called from your own UI, is what creates a run. Tappify posts it, draws the
card, and calls `POST /tappify/actions/send-push` only once the owner has approved. The extension
never sees the decision — it reads the status of the run it asked for and nothing else.
The assistant is the other way in, and it stops at the same place. When an owner's question leads
it to one of your actions, it creates the run and the run stays `pending`: the owner reads the
card, and your server is called on their approval, not on the assistant's request. Nothing the
assistant decides reaches you without an owner's yes in between.
## Limits and the record
Two hundred runs a day per project, counted when the run is created, so a run the owner rejects
spends one too. Every run is kept with who asked, who approved, the input it was created with and
what came back, and owners read their extension's runs on its page in their workspace. The share
of runs owners approve over 90 days is one of the four numbers on your listing: under 60 % the
listing is flagged, with the share spelled out as the reason.
# Add assistant tools
Source: https://docs.tappify.ai/extensions/build/add-assistant-tools
Let the assistant call your server, and let Tappify draw the answer as a card.
A tool is a name, an input schema, and the shape of the answer. The assistant calls it when the
owner's question needs it, your server answers with data, and Tappify draws the card.
```bash theme={null}
tappify extension add tool compare --returns comparison --cost low
```
Leave the flags off and the command asks what the tool answers with and how expensive it is to
run. It writes `server/tools/compare.ts` and `schemas/compare.input.json`, adds the handler to
your server's `tools` map, adds the `ai:tools` scope, and gives the manifest a `server` block if
it has none.
## The manifest entry
```json theme={null}
{
"scopes": [{ "key": "ui:render" }, { "key": "ai:tools" }],
"contributes": {
"ai": {
"tools": [
{
"id": "compare",
"description": "Compares two cohorts in the funnel and says which converted better.",
"input": { "$ref": "./schemas/compare.input.json" },
"returns": "comparison",
"cost": "low",
"cache": "5m"
}
]
}
},
"server": { "baseUrl": "https://funnel-lab.dev" }
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
| Field | Means |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Lowercase letters, digits and dashes. It is the key your handler registers under and the path Tappify posts to |
| `description` | 10 to 500 characters. What the assistant reads when it decides whether to call you. Tappify cuts it to 400 before the assistant sees it |
| `input` | The JSON Schema for the arguments the assistant passes. `tappify extension types` types your handler from it. At most 16 KB serialised |
| `returns` | Which of the five cards the host draws from your answer |
| `cost` | `low`, `medium` or `high`, so the assistant can prefer the cheaper of two tools that answer the same question |
| `cache` | `1m`, `5m` or `1h`. Optional |
`description` is the only thing the assistant has to go on. Write it as one sentence about what
the tool answers, in the owner's words rather than yours: "Compares two cohorts in the funnel"
beats "Runs the cohort comparison endpoint".
The assistant reads the `title` and `description` strings inside `input` too, so they are held to
the same rules as the tool description: they are scanned at publish, they are cut to 400
characters before the assistant sees them, and each one is at most 500 characters in the manifest.
Describe the argument, the way you would in your own API reference; an instruction addressed to
the assistant fails the publish scan on a public extension.
With `cache`, a repeat call carrying the same input for the same install is answered from
Tappify's own cache for that window and your server never sees it. The key is the install, the
tool and the input — not the owner who asked — so two owners of the same install share one
window. Don't personalise a cached answer by `claims.userId`; leave `cache` off for a tool whose
answer differs per owner.
## The handler
```ts theme={null}
import { createTappifyHandler } from "@tappify/extension-sdk/server";
export default createTappifyHandler({
extensionId: "funnel-lab",
tools: {
compare: async (request) => {
const { cohortA, cohortB } = request.input;
const result = await compareCohorts(request.context.projectId, cohortA, cohortB);
return {
label: `${cohortA} vs ${cohortB}`,
left: { label: cohortA, value: result.a },
right: { label: cohortB, value: result.b },
};
},
},
});
```
Tappify posts `POST /tappify/tools/compare` with the body every host-to-vendor call carries:
`request.input` typed from your schema, `request.context`, `request.credentials` and
`request.documents`. A tool call has no owner filter behind it — the model asked, not a dashboard
— so `request.context.filters` carries the instant of the call and the neutral platform and
country. Answer within 30 seconds and under 1 MB; a tool call is never retried.
## What each `returns` value must answer with
| `returns` | Keys the answer needs | What the host draws |
| ------------ | ------------------------------------------------- | -------------------------------------------- |
| `value` | `label`, `value`, and optionally `delta` | One figure, with `delta` as a pill beside it |
| `series` | `label`, `points` as `[x, y]` pairs | A sparkline over the last 240 points |
| `table` | `columns` as `{ key, label }`, `rows` | The first 8 rows, then `+N more` |
| `list` | `items` as `{ id, label, description? }` | The first 6 items, then `+N more` |
| `comparison` | `label`, `left` and `right` as `{ label, value }` | The two sides beside each other |
A `value` is a number or a string you have already formatted; a point's `y` is a number and its
`x` is a label.
An answer missing a key its `returns` promises comes back as `TOOL_RESPONSE_INVALID` and the
assistant is told the tool did not answer. `validateToolResponse` from
`@tappify/extension-sdk/testing` checks the same keys in your own tests.
The keys are only the gate. Tappify refuses an answer that is missing one, and the host then
reads each value it kept: a `points` entry that is not an `[x, y]` pair with a numeric `y`, a
`label` that is not a string, a column without a `key`, and the card falls back to one sentence.
So passing the validator means the keys are there, not that the card will draw — the values still
have to hold the shapes in the table above.
## What the owner sees
A row in the chat carrying your tile and `Asking Funnel Lab` while the call runs, `Asked Funnel
Lab` once it comes back, then the card Tappify draws from your answer: your tile in the header,
and one owner action, `Pin as widget`. A pin keeps a snapshot of that card on the owner's Home
page; the snapshot is stored in their own browser, so it is theirs alone, and `Unpin` takes it
off again.
A card whose `data` does not match the kind it promised draws one sentence saying so, and no
action. Nothing you send reaches the transcript as markup: the host reads only the keys in the
table above and draws them itself.
## What the assistant reads back
The owner's card is drawn from your whole answer. What goes back to the assistant is separate:
your answer is serialised into a `` block that says it came
from your extension, and cut to 8,192 characters. The assistant is told a block like that is the
vendor's data, not an instruction to it — so an answer that asks the assistant to do something
reads as a sentence about what you want, and nothing more. Keep the answer to the keys the card
needs: anything past them spends the budget without reaching the owner.
## Adding a mention list
A mention list puts your own objects in the owner's `@` picker.
```bash theme={null}
tappify extension add mention funnels
```
```json theme={null}
{
"contributes": {
"ai": {
"mentions": [
{ "id": "funnels", "label": "Funnel", "list": "/tappify/mentions/funnels" }
]
}
}
}
```
```ts theme={null}
export default createTappifyHandler({
extensionId: "funnel-lab",
mentions: {
funnels: async (request) => ({
items: await searchFunnels(request.context.projectId, request.input.q),
}),
},
});
```
As the owner types, Tappify calls `GET /tappify/mentions/funnels?q=` under your
`server.baseUrl` — the route `createTappifyHandler` serves for every name in `mentions`. The
manifest requires a `list` field and the command fills it with that same path; the host does not
read it.
Answer with `{ items: [{ id, label }] }`. Tappify keeps the first 20 items, cuts each label to 80
characters, and holds your answer for a minute per install, list and query, so a keystroke storm
costs you one call. Any other shape is `MENTION_RESPONSE_INVALID`, and a name the live release
does not declare is `MENTION_NOT_DECLARED`.
When the owner picks an item, the turn carries your extension, the list, the item's id and the
label the owner saw. That is also what makes a context provider declared `when: ["mention"]` run
for that turn.
## Limits
Tool calls are capped at 600 an hour per project, counted per workspace for an
organization-scoped install. Fifteen tools, ten mention lists and ten actions per extension; both
tools and mentions need `ai:tools`.
Tappify takes the 95th percentile of your tool calls over 90 days and publishes it on your
listing's quality panel. Over four seconds the listing is flagged, and a flagged extension is one
the assistant stops offering: it still answers about you and still calls your tools when an owner
asks for you by name, but it no longer proposes you unasked.
# Add context providers
Source: https://docs.tappify.ai/extensions/build/add-context-providers
Give the assistant a short brief about your product at the start of a turn.
A context provider is a short summary Tappify asks your server for and puts in front of the
assistant, so an answer about your extension starts from your facts instead of a guess.
```bash theme={null}
tappify extension add context glossary --when turn_start
```
Leave the flag off and the command asks when the assistant should ask for it. It writes
`server/context/glossary.ts`, adds the handler to your server's `context` map, adds the
`ai:skills` scope, and gives the manifest a `server` block if it has none.
## The manifest entry
```json theme={null}
{
"scopes": [
{ "key": "ui:render" },
{
"key": "ai:skills",
"justification": "The glossary tells the assistant what our funnel stages count before it answers about them."
}
],
"contributes": {
"ai": {
"context": [
{
"id": "glossary",
"description": "what our funnel stages mean",
"when": ["turn_start", "mention"],
"cache": "5m"
}
]
}
},
"server": { "baseUrl": "https://funnel-lab.dev" }
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
`description` is 10 to 300 characters and finishes the line the assistant reads above your
summary: `Context from Funnel Lab (what our funnel stages mean):`. Write it as a noun phrase, in
lower case, with no full stop; the label line carries the first 300 characters of it and counts
towards the block's 2 KB.
`when` decides how often you are asked:
| Value | Called |
| ------------ | ----------------------------------------------------------------- |
| `turn_start` | On every turn in a project where your extension is installed |
| `mention` | Only on a turn where the owner named one of your objects with `@` |
A turn that carries a mention runs both sets, and a provider that declares both phases still
answers once. Use `turn_start` for something short that is true all the time, and `mention` for
anything that depends on which object the owner picked.
## The handler
```ts theme={null}
import { createTappifyHandler } from "@tappify/extension-sdk/server";
export default createTappifyHandler({
extensionId: "funnel-lab",
context: {
glossary: async (request) => ({
summary:
"Stage 2 is first open, not install. Cohorts are calendar weeks starting Monday.",
data: { stages: ["install", "open", "activated", "paid"] },
}),
},
});
```
Tappify calls `GET /tappify/context/glossary` under your `server.baseUrl` with the install's
token. It is a GET, so it carries no body: no input, no credentials and no stored documents reach
this route. Anything that needs those belongs in a tool, which the assistant calls with the whole
call body.
Answer with `{ summary, data? }`. `summary` is prose the assistant can quote as it stands;
`data` is the numbers behind it. Anything else is refused and the block is dropped.
## The 2 KB cap
The whole block — the label line, your summary and your serialised `data` — is capped at 2 KB.
Over the cap Tappify drops `data` first, and only then trims `summary`, at a word boundary. Write
the summary so the first sentence is the one that matters.
## When your server is slow or down
A context provider runs before the assistant starts answering, so the owner waits for it. You
have 3 seconds — the health-check budget, not the 30 a tool call gets. Past that, or on any
failure, Tappify drops the block. The owner is not shown an error and the turn carries on without
your context. The call is recorded against the install as a `context_call` with an error outcome:
your Runtime page counts those calls by type over the last thirty days, and the owner sees the
failed one in their install's recent activity.
A provider that fails is then left alone for a minute: the next turns skip it without calling
you, so a server having a bad minute is asked once, not once a turn. Anything you cannot compute
in 3 seconds belongs behind `cache`, or in a tool the assistant calls when it actually needs it.
## Limits
Three context providers per extension, each block at most 2 KB, each call answered within 3
seconds. `cache` accepts `1m`, `5m` or `1h`, and Tappify keys the cache on the install and the
provider, so one workspace's block never reaches another's. The owner who asked is not part of
that key, so don't personalise a cached block by `claims.userId` — the three owners of one
install share the window. Skills, prompts and context providers all sit behind the same
`ai:skills` scope, so declaring your first one asks the owner for it once.
# Add knowledge
Source: https://docs.tappify.ai/extensions/build/add-knowledge
Ship markdown Tappify indexes at publish and cites when the assistant uses it.
A knowledge file is markdown that explains your product to the assistant. Tappify indexes it when
you publish and pulls the matching paragraphs into a turn, with your tile on the citation.
```bash theme={null}
tappify extension add knowledge numbers --category metric_definitions
```
Leave the flag off and the command asks which kind it is. It writes `knowledge/numbers.md` and
adds the `ai:skills` scope, which is the one skills, prompts and context providers need. A
knowledge file on its own needs no scope: Tappify indexes it at publish and retrieves it for any
install of your extension.
## The manifest entry
```json theme={null}
{
"contributes": {
"ai": {
"knowledge": [
{
"id": "numbers",
"category": "metric_definitions",
"file": "knowledge/numbers.md"
}
]
}
},
"server": { "baseUrl": "https://funnel-lab.dev" }
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
Any `contributes.ai` block needs a `server.baseUrl`, knowledge included, because the rest of the
block is routes on your server.
`file` is a markdown path inside your repository, at most 200 characters. An absolute path, a
drive prefix or a `..` fails the publish with `knowledge.file`, and a file the bundle does not
carry fails it with `KNOWLEDGE_FILE_MISSING` before a release row is written, so a typo costs you
a publish and nothing else. At most 20 knowledge files.
## The three categories
| Category | What belongs in it |
| -------------------- | ---------------------------------------------------------------------------- |
| `metric_definitions` | What each number you publish counts, and what it does not |
| `gotchas` | Where owners get it wrong: time zones, sampling, a delay before data appears |
| `setup` | What the owner has to do on your side before your numbers mean anything |
A connector has to ship a `metric_definitions` file. Your numbers sit next to Tappify's own on
the same chart, so the assistant has to be able to say what yours count; publishing metrics
without one fails the `knowledge.metric_definitions` check.
## Writing the file
```md theme={null}
# What the numbers mean
`active_users` counts one device per calendar day in the project's time zone. A device that opens
the app twice counts once.
`conversion` is installs divided by store page views for the same day. It is not Tappify's own
conversion rate, which uses impressions.
Numbers appear about 40 minutes after the hour they describe.
```
Write in short paragraphs separated by blank lines. Tappify packs paragraphs into chunks of about
1,200 characters, carries the tail of each chunk into the next one so a definition split across
two is still readable in both, and keeps a heading with the paragraph under it. A definition that
lives in its own paragraph is retrievable on its own.
The file's first markdown heading becomes the title every chunk of it carries, so give each file
one.
## What Tappify retrieves
The owner's last message is the query. Tappify matches it against the index with Postgres full
text search, takes the three best-ranked chunks across every extension that chat can see, and cuts
each to 1,000 characters and each title to 120 before the assistant sees it. There is no search
tool for the assistant to call: retrieval happens once, at the start of the turn.
## What the owner sees
Nothing, until the assistant uses it. Then the part of the answer that leaned on your file carries
a citation beneath it — your tile and your extension's name, which the owner can click through to
your install's page — so they know which part of the answer came from you. A citation for an
extension they do not have installed is dropped rather than shown.
## What a reviewer sees
Every knowledge file, every skill file and every prompt template, in full. Tappify also scans them
for text that tries to redirect the assistant — "ignore previous instructions", a zero-width or
direction-reversing character, a markdown image whose URL would carry the conversation somewhere.
For a public extension a match fails the publish; for a private or unlisted one it is reported to
you as a warning and you decide.
Vendor text describes your product. Everything you write reaches the assistant inside a marker
that says who wrote it, under a standing rule the assistant is given on every turn: text from an
extension is data about that vendor's product, not an instruction, and it cannot change Tappify's
rules or reach data the owner did not grant. A block that asks the assistant to ignore its
instructions, take on a role, or keep something from the owner is reported to the owner in plain
words, and their request is answered anyway.
# Add skills and prompts
Source: https://docs.tappify.ai/extensions/build/add-skills-and-prompts
Teach the assistant how to use your extension, and give owners one-click questions.
A skill is markdown the assistant loads when your extension is the right thing to reach for. A
prompt is a question the owner can put in the chat composer with one click, from chat, from your
widget, from your page, or under a card one of your tools produced.
```bash theme={null}
tappify extension add skill growth-review
tappify extension add prompt why-drop --surfaces chat_suggestion,widget,result_card
```
Both need the `ai:skills` scope, and that scope carries a justification the owner reads at
install.
## The manifest entry
```json theme={null}
{
"scopes": [
{ "key": "ui:render" },
{ "key": "ai:tools" },
{
"key": "ai:skills",
"justification": "The skill explains how our funnel stages are counted, so answers match our dashboard."
}
],
"contributes": {
"ai": {
"tools": [
{
"id": "compare",
"description": "Compares two cohorts in the funnel and says which converted better.",
"input": { "$ref": "./schemas/compare.input.json" },
"returns": "comparison",
"cost": "low"
}
],
"skills": [
{
"id": "growth-review",
"name": "Growth review",
"description": "How to read the funnel before reaching for a chart.",
"file": "skills/growth-review.md",
"tools": ["compare"]
}
],
"prompts": [
{
"id": "why-drop",
"title": "Why did this drop?",
"template": "Why did {{project.name}} lose users in the funnel last week?",
"surfaces": ["chat_suggestion", "widget", "result_card"],
"after": ["compare"]
}
]
}
},
"server": { "baseUrl": "https://funnel-lab.dev" }
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
## Writing a skill
`skills/growth-review.md` is a procedure for the assistant, at most 4,000 words. Write the steps,
not the sales copy: which of your tools to call first, what your numbers mean, and when your data
does not answer the question.
```md theme={null}
# Growth review
Start with `compare` on the two cohorts the owner named. If they named one, compare it to the
project's median cohort.
Stage names are ours: `install` is a store install, `activated` is a first session over
30 seconds. Do not equate `activated` with Tappify's own page views.
If the range is under seven days, say the sample is short before giving a number.
```
A longer file fails publish with `bundle.skill_size`, because the assistant reads all of it the
moment it picks the skill. Reference material belongs in a knowledge file instead, which is
retrieved a paragraph at a time.
`name` is at most 60 characters and `description` 10 to 300. Together they are all the assistant
sees until it picks your skill: Tappify lists your skills by name and description on every turn
and loads the body only when the assistant selects one, so length costs you nothing until it is
used.
`tools` lists your own tool ids. A skill may only rely on the tools its own extension declares;
naming another extension's tool fails the publish with `skills.tools`.
Tappify registers your skills when the install is created, when the owner grants `ai:skills`, and
when an install is resumed, and re-reads them from the new manifest whenever one of your releases
goes live. A paused, retired or uninstalled install keeps none. At most 5 skills.
## Writing a prompt
`template` is the question, with placeholders Tappify fills in. `{{project.*}}` is resolved on
the server before the chip reaches the browser; `{{context.*}}` and `{{input.*}}` are resolved in
the owner's browser when they click.
| Placeholder | Filled from |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `{{project.id}}`, `{{project.name}}`, `{{project.slug}}` | The project the owner is in |
| `{{context.*}}` | The surface the chip sits on: a widget's own context, or a page's request parameters |
| `{{input.*}}` | A form Tappify shows before filling the composer, built from the prompt's `input` schema |
Those three are the whole of `{{project.*}}`. A chip in the chat's suggestion row or under a
result card carries no context of its own, so a `{{context.*}}` there resolves to nothing — and
so does any other key Tappify does not recognise. The sentence around it is tidied up rather than
left with a gap, which is why a template reads better when the placeholder is a whole clause. A
template that leans on the surface — `Why did {{project.name}} lose users at {{context.stage}}?` —
belongs on a prompt declared for `widget` or `page`, which are the surfaces that carry one.
`surfaces` says where the chip is offered:
| Surface | Where the chip renders |
| ----------------- | ---------------------------------------------------------------------------- |
| `chat_suggestion` | The chat's suggestion row, after Tappify's own |
| `widget` | A host strip under your widget |
| `page` | Under your page or tab, and in the footer when a widget of yours is expanded |
| `result_card` | Under a card one of your tools produced |
`after` names your own tool ids, and the manifest refuses any other extension's. A `result_card`
chip is offered only under a card one of the tools in `after` produced.
`title` is at most 60 characters, `template` at most 2,000, and the resolved question a chip
carries is cut to 500. At most 20 prompts.
## Asking for input first
```json theme={null}
{
"id": "compare-cohorts",
"title": "Compare two cohorts",
"template": "Compare {{input.a}} with {{input.b}} for {{project.name}}",
"surfaces": ["page"],
"input": { "$ref": "./schemas/compare-cohorts.input.json" }
}
```
Tappify draws that schema as a form in a host dialog, and the composer fills only once the owner
submits it. A chip that asks first carries a trailing ellipsis, so the owner knows the click opens
a form rather than filling the composer.
## What the owner sees
A chip with your tile and your `title`. Clicking it puts the finished question in the owner's
composer and stops there: they read it, edit it if they want, and press send themselves. Nothing
you write is ever sent on their behalf. Tappify offers at most two of your chips in any one row,
after its own.
When the owner does send, the turn records a `prompt_sent` against the chip it came from. A chip
naming a prompt the live release no longer declares is refused with `PROMPT_NOT_DECLARED`.
## Opening the chat from your own surface
Your widget and your page can reach the composer directly, without a declared prompt.
```tsx theme={null}
tap.nav.openChat("Why did activations fall last week?", { stage: "activated" });
```
The prompt lands in the composer, and the context object lands under it on a visible line reading
`From :`, which the owner can read and edit before they send. That context
is capped at 2 KB and a cut is marked with an ellipsis.
```tsx theme={null}
tap.ui.openInChat(card);
```
With `insights:write`, that puts your card into the transcript as a card the host draws, and
opens the chat drawer. Without the scope it falls back to a sentence Tappify writes about the
card, in the composer, for the owner to send.
# Raise alerts, banners, and markers
Source: https://docs.tappify.ai/extensions/build/alerts-banners-markers
Send an event from your server and have Tappify put it in the owner's inbox, at the top of a page, or on a chart.
Your server sends one signed request; Tappify decides where it lands. A webhook declared as an
alert reaches the owner's inbox, one declared as a banner sits at the top of a page until
dismissed, and one declared as an event reaches your own widgets and, when a marker names it, the
time axis of a chart.
```bash theme={null}
tappify extension add webhook anomaly.detected --as alert
tappify extension add marker rollout --chart "*" --event rollout.finished --glyph "⚑"
```
A command that declares something your server answers also writes a `server.baseUrl` when the
manifest has none, using the placeholder `https://.example.com`. That address answers
nothing, so `tappify extension publish` stops on its health check until you point `baseUrl` at a
server you have deployed.
## The manifest entry
```json theme={null}
{
"scopes": [
{ "key": "ui:render" },
{
"key": "alerts:write",
"justification": "Tells owners when installs fall off a cliff so they can act the same day."
}
],
"contributes": {
"webhooks": [
{
"event": "anomaly.detected",
"as": "alert",
"payload": {
"type": "object",
"properties": {
"why": { "type": "string" },
"metric": { "type": "string" }
},
"required": ["why"]
}
},
{
"event": "anomaly.spiked",
"as": "banner",
"payload": {
"type": "object",
"properties": {
"why": { "type": "string" },
"metric": { "type": "string" }
},
"required": ["why"]
}
},
{
"event": "rollout.finished",
"as": "event",
"payload": {
"type": "object",
"properties": { "flag": { "type": "string" } },
"required": ["flag"]
}
}
],
"banners": [
{ "id": "spike", "page": "analytics", "event": "anomaly.spiked" }
],
"markers": [
{
"id": "rollout",
"label": "Flag rolled out",
"chart": "*",
"event": "rollout.finished",
"glyph": "⚑"
}
]
},
"server": { "baseUrl": "https://funnel-lab.dev" }
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
| `as` | Where it lands |
| -------- | -------------------------------------------------------------------------------------------------------- |
| `alert` | The owner's inbox, with your tile and the `why` sentence |
| `banner` | The top of the page a `banners` contribution names, until the owner dismisses it |
| `event` | `tap.data.subscribe` in your own widgets, and the chart time axis when a `markers` contribution names it |
An event name is `domain.verb`, both parts `snake_case`, and one webhook carries one `as`. A banner
contribution names a webhook you declared with `as: "banner"`, and a marker names one you declared
with `as: "event"`; naming an event nothing declares under that `as` fails the manifest schema. An
event you want in the inbox and at the top of a page is two webhooks under two names, as
`anomaly.detected` and `anomaly.spiked` are above — Tappify resolves a delivery by its name alone,
so one name never carries two outcomes.
An `alert` or a `banner` payload must declare a required `why` string. That sentence is the whole
message the owner reads, so write it as one: `Installs fell 40% in an hour`, not
`ANOMALY_DETECTED`.
A marker's `chart` is one host chart or `"*"` for every one. Its `glyph` is at most two characters
and Tappify draws it, inside its own axis, with your tile on hover.
Declaring an `alert` or a `banner` needs `alerts:write`; a marker needs `ui:render` as well,
because it draws in the host.
## Sending one
`sendEvent` builds the envelope, signs it, and posts it.
```ts theme={null}
import { sendEvent } from "@tappify/extension-sdk/server";
await sendEvent(
"anomaly.detected",
{ why: "Installs fell 40% in an hour", metric: "installs" },
{
installId,
extensionId: "funnel-lab",
secret: process.env.TAPPIFY_INBOUND_SECRET,
dedupeKey: `anomaly-${projectId}-${hour}`,
build: "1042",
},
);
```
The event name and the payload type both come from your generated types, so a name you never
declared or a missing `why` is a compile error.
## The envelope, if you send it yourself
```http theme={null}
POST /api/v1/extensions/hooks/funnel-lab/{installId}
X-Tappify-Signature:
Content-Type: application/json
{
"event": "anomaly.detected",
"source": "funnel-lab",
"occurredAt": "2026-09-09T10:00:00Z",
"build": "1042",
"dedupeKey": "anomaly-prj_1-2026090910",
"payload": { "why": "Installs fell 40% in an hour", "metric": "installs" }
}
```
The signature is the lowercase hex HMAC-SHA256 of the exact bytes you send, keyed on the inbound
secret from your extension's Server page. `source` is your extension id, and a `source` that is
not yours is refused. `build` is the owner's app version or build number when you know it, which
is what lets an alert land against a release.
Tappify answers 202 to every delivery it stores, whatever the delivery became.
## Resending
`dedupeKey` is yours to choose and Tappify stores it per install; leave it out and `sendEvent`
sends a fresh UUID, which makes every retry a new delivery. Send the same key twice and the second
delivery is recorded as a duplicate: no second alert, no second banner, no error. That makes a
retry loop on your side safe.
## Limits
Sixty deliveries a minute per install. Three alerts a day per project — a fourth is stored and
marked rate-limited so both of you can see it happened, but the owner is not told. An alert or a
banner on an install that never granted `alerts:write` is stored as rejected for the same reason.
Events are not counted against the alert limit.
## Replaying
An owner can re-run a delivery from the extension's page in their workspace, and you can re-run
one from your Runtime page. A replay re-runs the outcome against the stored payload; it does not
create a second delivery and does not call your server. Replaying an alert spends the project's
allowance for that day again, and is refused with `ALERT_LIMIT_REACHED` when the day is already
spent.
# Deploy your server
Source: https://docs.tappify.ai/extensions/build/deploy-your-server
Run the starter's handler on Cloudflare Workers or Vercel, and point your manifest at it.
Tappify never runs your code on its servers. An extension that answers procedures, syncs
metrics or performs actions runs its own server, and the starter ships one handler behind two
entries.
```bash theme={null}
pnpm run deploy:worker
tappify extension set server.baseUrl https://my-extension-server..workers.dev
```
## One handler, two entries
`server/handler.ts` exports the options and the handler:
```ts theme={null}
import {
createTappifyHandler,
type TappifyFetchHandler,
type TappifyHandlerOptions,
} from "@tappify/extension-sdk/server";
import { procedures } from "./procedures";
export const handlerOptions: TappifyHandlerOptions = {
extensionId: "starter",
health: () => ({ ok: true, version: "0.1.0" }),
procedures,
};
export const handler: TappifyFetchHandler = createTappifyHandler(handlerOptions);
```
It is a `(Request) => Promise`, so it runs anywhere the Fetch API does. The options
are exported separately because a platform that mounts you under a prefix needs `basePath`.
`server/worker.ts`:
```ts theme={null}
import { handler } from "./handler";
export default { fetch: handler };
```
`wrangler.toml`:
```toml theme={null}
name = "my-extension-server"
main = "server/worker.ts"
compatibility_date = "2026-09-01"
compatibility_flags = ["nodejs_compat"]
[observability]
enabled = true
```
```bash theme={null}
pnpm run deploy:worker
```
`api/tappify/[...path].ts`:
```ts theme={null}
import { createTappifyHandler } from "@tappify/extension-sdk/server";
import { handlerOptions } from "../../server/handler";
export const config = { runtime: "edge" };
export default createTappifyHandler({ ...handlerOptions, basePath: "/api" });
```
Vercel serves the file at `/api/tappify/…`, so `basePath` is what makes the handler see
the `/tappify/…` paths it routes on. Deploy the repository and set `server.baseUrl` to
`https://your-app.vercel.app/api`.
```ts theme={null}
import express from "express";
import { toExpress } from "@tappify/extension-sdk/server";
import { handler } from "./handler";
const app = express();
app.use(toExpress(handler));
app.listen(3000);
```
`toNode(handler)` returns a plain `http` request listener for a server without Express.
## Point the manifest at it
```bash theme={null}
tappify extension set server.baseUrl https://my-extension-server..workers.dev
tappify extension set server.sandboxBaseUrl https://my-extension-staging..workers.dev
```
`sandboxBaseUrl` is used for dev installs and sandbox projects. It defaults to `baseUrl`.
Both must be `https`.
## The routes Tappify calls
| Route | Called when |
| ---------------------------------- | ----------------------------------------------- |
| `GET /tappify/health` | At every publish, once per base url you declare |
| `POST /tappify/procedures/:name` | Your UI called `tap.server.` |
| `POST /tappify/metrics` | Connector sync, on your declared cadence |
| `POST /tappify/tools/:toolId` | The assistant called one of your tools |
| `GET /tappify/mentions/:mentionId` | The owner opened the mention picker |
| `POST /tappify/actions/:actionId` | The owner approved an action |
| `GET /tappify/context/:contextId` | A chat turn started, or a mention resolved |
| `POST /tappify/events` | A Tappify event your manifest subscribes to |
| `POST /tappify/work/:operation` | A work-destination operation |
`createTappifyHandler` routes all of them; you fill in the ones you declared. A path it routes
with no handler registered comes back as a 404 naming the missing one, so a contribution you
declared and never wired is loud rather than silent.
## Verifying the caller
Every route except health carries `Authorization: Bearer ` — RS256, five
minutes, audience `ext:` — and `X-Tappify-Event-Id`, the idempotency key.
The handler verifies the token for you against Tappify's JWKS. Verify it yourself only when
you are not using the handler:
```ts theme={null}
import { verifyTappifyToken } from "@tappify/extension-sdk/server";
const claims = await verifyTappifyToken(token, { extensionId: "starter" });
```
The keys are at
`https://api.tappify.ai/api/v1/extensions/.well-known/jwks.json` and rotate with a 24-hour
overlap, so both keys are served through the change.
## Before you publish
Health has to answer 200 within three seconds, on `baseUrl` and on `sandboxBaseUrl` if you
declared one, or the publish stops. A redirect counts as a failure: serve the route from the
url you declared.
`tappify extension dev --live` serves your bundle from your machine but still relays
procedures to the url in your manifest. Run the handler locally with `pnpm run dev:worker`,
give it a public address, and pass that to `--server ` for the length of the session —
Tappify calls you from its own servers, so a `localhost` url is not one it can reach. See
[Local server during live preview](/extensions/test/local-server).
# Fetch data with hooks
Source: https://docs.tappify.ai/extensions/build/fetch-data-with-hooks
Read the owner's store data and call your own server through hooks that cache, dedupe and invalidate.
`useTapQuery` reads the owner's Tappify data. `useTapServer` calls one of your declared
procedures. Both return the same object, cache per install and input, dedupe across mounts,
and need no data library of your own.
```tsx theme={null}
import { useTapFilters, useTapQuery, useTapServer } from "@tappify/extension-sdk";
const filters = useTapFilters();
const downloads = useTapQuery({
kind: "series",
metric: "downloads",
range: filters.range,
});
const summary = useTapServer("getSummary", { days: 7, platform: "all" });
```
Both give you `{ data, error, isLoading, refetch }`. A result stays fresh for 30 seconds
before the next mount asks again, and a failed call is not retried — `refetch()` is yours to
call.
## The queries you can run
| Query | Returns | Needs |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------- |
| `{ kind: "project" }` | The project, its apps, releases and keywords | `projects:read` |
| `{ kind: "series", metric, range, platform? }` | A daily series. `metric` is `downloads`, `impressions`, `page_views` or `conversion_rate` | `analytics:read` |
| `{ kind: "keywords" }` | The tracked keyword set with positions | `store.metadata:read` |
| `{ kind: "listing" }` | Store listing metadata per locale | `store.metadata:read` |
| `{ kind: "reviews", range }` | Review text, ratings and replies | `reviews:read` |
| `{ kind: "crashes", range }` | Crash-free rate and issue counts | `crashes:read` |
| `{ kind: "revenue", range }` | Revenue series and refund counts | `revenue:read` |
Reads come from Tappify's own database, never live from the App Store or Google Play, so a
query is fast and cannot be rate-limited by a store.
A query whose scope the owner has not granted is refused before it reaches the data: nothing
comes back, and the hook's `error` names the scope that kind needs. Ask the bridge first when
a surface is optional:
```tsx theme={null}
import { TapEmptyState, useTap } from "@tappify/extension-sdk";
const tap = useTap();
if (!tap.auth.can("revenue:read")) {
return ;
}
```
## Options
```tsx theme={null}
const summary = useTapServer("getSummary", input, {
enabled: filters.platform !== "all",
invalidateOn: ["release.shipped", "settings.changed"],
});
```
| Option | Means |
| -------------- | ------------------------------------------------ |
| `enabled` | Skip the call until the condition holds |
| `invalidateOn` | Refetch when one of these Tappify events arrives |
`tap.invalidate("getSummary")` invalidates one procedure by name across every mount;
`tap.invalidate()` invalidates everything of yours.
## One request per input, not per mount
Two widgets that ask for the same series in the same range make one request. The cache key is
the install, the query and the filters behind it, so the same numbers render the same way in
the slot, in the expand panel and in the tab.
A procedure call carries `tap.filters` for you. A data query carries only the country
picker — `range` and `platform` are the ones you put in the query, which is why the sample
above passes `filters.range`. See
[Follow the page's filters](/extensions/build/follow-the-filters).
# Follow the page's filters
Source: https://docs.tappify.ai/extensions/build/follow-the-filters
Read the host page's range, platform and country pickers so your numbers move with them.
The Analytics page carries a date range picker. `useTapFilters()` gives you its current value
there and re-renders when the owner changes it; `platform` and `country` come through the same
call but hold the project's defaults today (iOS, all countries), because the host has no picker
for them yet. On a surface with no picker bar — your own page, or a widget on another host
page — the same call gives you the project's current defaults and stays put.
```tsx theme={null}
import { useTapFilters, useTapQuery } from "@tappify/extension-sdk";
export function Downloads() {
const filters = useTapFilters();
const downloads = useTapQuery({
kind: "series",
metric: "downloads",
range: filters.range,
platform: filters.platform === "all" ? undefined : filters.platform,
});
return {downloads.data?.points.length ?? 0} days ;
}
```
## What you get
| Field | Type | Means |
| ------------------------ | ----------------------------- | ----------------------------------------------------------- |
| `range.from`, `range.to` | `YYYY-MM-DD` strings | The window the page is showing |
| `range.preset` | `"7d" \| "30d" \| "90d"` | The preset behind that window, absent on the one-year range |
| `platform` | `"ios" \| "android" \| "all"` | The project's platform; `ios` until the host has a picker |
| `country` | a country code or `"all"` | The country picker |
## What travels on its own
A procedure call carries all three without you passing anything: your server reads the same
window in `request.context.filters`, and a widget that shows one number needs no code to
follow the picker.
A data query is written by you, so its `range` and `platform` are the ones in the query
object above. The country picker is the exception — the host adds it to every read where a
country is picked.
Both hooks key their cache on the install, the query or input, and the filters, so changing
a picker fetches and changing back reads the entry already held.
Read the filters yourself when what you show is not what you asked for: a label, a
comparison against the previous window, a copy button.
```tsx theme={null}
import { useTap, useTapFilters } from "@tappify/extension-sdk";
export function RangeLabel() {
const tap = useTap();
const filters = useTapFilters();
return (
{tap.format.date(filters.range.from)} to {tap.format.date(filters.range.to)}
);
}
```
`tap.format` renders numbers, currencies, dates and relative times the way Tappify renders
them, in the locale and time zone the host is running in. Use it rather than `Intl` directly
so your numbers and Tappify's read as one page.
# Build forms from schemas
Source: https://docs.tappify.ai/extensions/build/forms-from-schemas
Render a JSON Schema as a host-styled form with validation, instead of writing inputs by hand.
`TapForm` renders the same schema your manifest already declares — for a settings panel, an
action's input, or a credential set — as a form in the host's styling, validates on submit,
and hands you a typed value.
```tsx theme={null}
import { TapForm } from "@tappify/extension-sdk";
import schema from "../schemas/settings.json";
onChange(next)}
/>;
```
## Props
| Prop | Type | Means |
| ------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `schema` | `JsonSchema` | The object schema to render. One control per declared property |
| `value` | `Partial` | The values the fields start at, and later changes to it do not reset the form. Omit for an empty form |
| `submitLabel` | `string` | Label on the submit button. Defaults to `Submit` |
| `cancelLabel` | `string` | Label on the cancel button. Defaults to `Cancel` |
| `busy` | `boolean` | Disables the submit button and marks it busy while a submit is in flight |
| `onSubmit` | `(value: T) => void \| Promise` | Called with the validated value |
| `onCancel` | `() => void` | Renders a cancel button, and is called by it |
## What each schema construct renders
| Schema | Control |
| ---------------------------------------------- | ---------------------------------------------------------------------------- |
| `"type": "string"` | Single-line input |
| `"type": "string"` with `"format": "textarea"` | Textarea |
| `"type": "string"` with `"format": "date"` | Date input |
| `"enum"` on a property | Select, one option per value |
| `"type": "number"` or `"integer"` | Number input |
| `"type": "boolean"` | Checkbox |
| `title` | The control's label. Falls back to the property name, spaced and capitalised |
| `description` | The hint under the control |
| `required` | Marks the control required and blocks submit when empty |
Submit drops the fields left blank, then checks what remains against the whole schema, so a
`minimum`, a `pattern` or a `maxLength` you declared is enforced too and its message lands
under the control it belongs to.
`formFields(schema)` returns the same list `TapForm` renders, as
`{ name, label, kind, required, hint, options }`, when you want the fields without the form.
## An action behind a form
An action goes through an approval card, so the form collects the input and the host does the
rest:
```tsx theme={null}
import { TapForm, useTap, type ActionInput } from "@tappify/extension-sdk";
import schema from "../schemas/send-push.input.json";
export function SendPush() {
const tap = useTap();
return (
) => {
void tap.actions.run("send-push", input);
}}
/>
);
}
```
`tap.actions.run` posts the values, and Tappify draws the approval card — the scope, the blast
radius, whether it can be undone and the cost — before anything runs. See
[Add actions](/extensions/build/add-actions).
A form with no `value` has nothing to infer `T` from, so annotate the handler's parameter —
`ActionInput<"send-push">` here, `TapSettingsValues` in a settings panel. `onSubmit` returns
nothing, so hand the promise to `void` rather than returning it.
Point `TapForm` at the same file your manifest references. When the schema changes,
[`tappify extension types`](/extensions/build/generated-types) changes the type in the same
step, and a form that no longer matches its handler stops compiling.
# Generated types
Source: https://docs.tappify.ai/extensions/build/generated-types
Turn your manifest and its schemas into TypeScript so a wrong collection, procedure or setting is a compile error.
`src/tappify.d.ts` is generated from your manifest and the JSON Schemas it points at. It is
what makes `tap.storage.preferences`, `tap.server.getSummary` and `TapSettingsProps` know
their own shapes. Commit it.
```bash theme={null}
tappify extension types
```
`add`, `remove` and `dev` run it for you, and `dev` rewrites it every time the manifest
changes.
## What it declares
The file declares one named type per schema, then augments the SDK's module with maps that
point at them:
```ts theme={null}
export interface Preferences {
compact: boolean;
metric: 'downloads' | 'impressions';
}
declare module '@tappify/extension-sdk' {
interface TapStorageMap {
'preferences': { kind: 'singleton'; scope: 'user'; document: Preferences };
}
interface TapProcedureMap {
'getSummary': { input: GetSummaryInput; output: GetSummaryOutput };
}
interface TapTelemetryEvents {
'summary_viewed': true;
}
interface TapSettings extends Settings {}
}
```
| Declared from | Types it produces |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `contributes.storage` | `tap.storage.`, and `useTapStorage("")` for singletons |
| `server.procedures` | `tap.server.(input)`, `useTapServer("", input)`, and the server's `procedures.` handler |
| `contributes.settings.schema` | `TapSettingsProps["values"]`, the reserved singleton, and `documents.settings` on your server |
| `telemetry` | `TelemetryEvent`, so `tap.telemetry.event` takes only names you declared |
| `contributes.ai.tools` | The input type each `tools.` handler receives |
| `contributes.ai.actions` | `ActionId` and `ActionInput`, so `tap.actions.run` type-checks |
| `contributes.ai.prompts` | The placeholder types a prompt template resolves against |
| `contributes.webhooks` | `sendEvent` names and payloads |
| `contributes.connector.auth.fields` | `TapCredentialValues`, the shape your server reads as `credentials` |
| The Tappify event catalogue | The payload behind each event name, so `payload.version` on `release.shipped` resolves to a field rather than to `unknown` |
The event names themselves come with the SDK, so `tap.data.subscribe` rejects a name that
does not exist whether or not you have generated. What generating adds is the payload.
Query results are not generated: the seven query kinds are fixed, so `useTapQuery` already
knows what each returns.
## When it goes stale
[`tappify extension doctor`](/extensions/test/doctor-checks) compares the file with what the
generator would write now and fails the `types.stale` check when they differ.
`tappify extension doctor --fix` rewrites it.
Names fall back to `string` while a map is empty, so `useTapStorage("preferences")` and
`useTapServer("getSummary", input)` compile before you have generated anything. A document
and a procedure output fall back to `unknown`, so reading a field off one is a compile error
until the file exists. Generate once the manifest declares the contribution, and write the
component against real fields.
# Store data with hosted storage
Source: https://docs.tappify.ai/extensions/build/hosted-storage
Declare a document collection and read and write it through the bridge, with no server of your own.
Hosted storage is a set of JSON document collections Tappify stores for your install and
validates against a schema you declare. A UI-only extension needs no server to remember
anything.
```bash theme={null}
tappify extension add storage --name preferences --scope user --singleton
```
## The manifest entry
```json theme={null}
{
"contributes": {
"storage": {
"preferences": {
"scope": "user",
"singleton": true,
"schema": { "$ref": "./schemas/preferences.json" }
}
}
},
"scopes": [{ "key": "storage:write" }]
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
| `scope` | Who the document belongs to |
| -------------- | ------------------------------------------ |
| `user` | Each teammate keeps their own copy |
| `install` | One copy shared by everyone on the install |
| `organization` | One copy shared across the workspace |
`singleton: true` means one document per scope key, addressed with `get`, `set` and `patch`.
Leave it off and you get a collection with ids: `list`, `get`, `put`, `delete`.
A collection name is lower snake\_case, and one extension declares at most ten of them. The
name `settings` is reserved — it is the [settings
panel](/extensions/build/add-a-settings-panel)'s own document.
## Reading and writing
A singleton has a hook:
```tsx theme={null}
import { TapButton, useTapStorage } from "@tappify/extension-sdk";
export function CompactToggle() {
const preferences = useTapStorage("preferences");
const compact = preferences.data?.compact ?? false;
return (
void preferences.patch({ compact: !compact })}>
{compact ? "Expand" : "Compact"}
);
}
```
`useTapStorage` returns `{ data, error, isLoading, refetch, save, patch }`. `save` replaces
the document; `patch` merges and resolves to the merged document. `data` is `undefined` while
the first read is in flight and `null` until the document is first written.
A collection with ids goes through the bridge:
```ts theme={null}
import { useTap } from "@tappify/extension-sdk";
const tap = useTap();
const { items, cursor } = await tap.storage.funnels.list({ limit: 50 });
await tap.storage.funnels.put("weekly", { steps: ["view", "install"] });
await tap.storage.funnels.delete("weekly");
```
Both are typed from your manifest once you have run
[`tappify extension types`](/extensions/build/generated-types) — an unknown collection name
or a document that does not match the schema is a type error, not a runtime surprise.
## Rules
* Writes are validated against the schema. Reads are not, so adding a field costs nothing
and old documents keep loading. A change that breaks readers is a new collection name.
* Your install reaches only its own documents. There is no path to another install's or
another extension's.
* `install`- and `organization`-scoped documents are passed to your server read-only as
`documents` on every call, so your server never asks Tappify for them. Servers cannot
write storage.
* A write broadcasts to every mount of your extension and every open tab of that install.
* Uninstalling deletes every document. When a teammate leaves the workspace, their
`user`-scoped documents go with them. An owner can export an install's documents as JSON.
* Personal-data field names — `email`, `name`, `phone`, `address`, `ip` — put your release in
front of a reviewer. Do not store personal data here.
Limits: 256 KB per document, 1,000 documents per collection per scope key, 50 MB per
install, 300 storage calls per minute. They are all on
[Storage limits and rate limits](/extensions/reference/limits).
# Navigate inside your page
Source: https://docs.tappify.ai/extensions/build/navigate-inside-your-page
Move between sub-paths of your own page, read the segments back, and keep a selection in the URL.
Your page owns everything under its own path. `tap.nav.push` moves inside it,
`useTapParams()` reads the segments, and `tap.nav.setSearch` keeps a selection in the URL so
a link reopens what the owner was looking at.
```tsx theme={null}
import { useTap, useTapParams } from "@tappify/extension-sdk";
export default function Explore() {
const tap = useTap();
const params = useTapParams();
const funnelId = params["0"] === "funnels" ? params["1"] : undefined;
if (funnelId === undefined) {
return (
tap.nav.push("/explore/funnels/123")}>
Open funnel 123
);
}
return Funnel {funnelId} ;
}
```
## What the segments look like
`useTapParams()` hands back the sub-path split for you, keyed by position. There are no named
parameters, because your page declares no route pattern for the host to match.
| Key | Is |
| --------------- | ----------------------------------------------- |
| `pageId` | The id of the page contribution the owner is on |
| `path` | Everything under it, unsplit: `funnels/123` |
| `"0"`, `"1"`, … | Those same segments, one per key |
On `/projects/prj_1/ext/funnel-lab/explore/funnels/123` that is
`{ pageId: "explore", path: "funnels/123", "0": "funnels", "1": "123" }`.
## Where you can go
| Call | Accepts |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tap.nav.push(path)` | A sub-path of your own page, or an absolute path under the current project |
| `tap.nav.setSearch(params)` | Query parameters on the current URL. `null` removes one |
| `tap.nav.openSettings()` | The owner's page for your install |
| `tap.nav.openChat(prompt, context?)` | The assistant, opened with your prompt in the owner's composer for them to send. `context` goes under it on a line naming your extension, capped at 2 KB |
| `tap.nav.openExternal(url)` | Anywhere outside Tappify, through the host's exit affordance |
A path that starts with `/projects/` is taken as written; anything else is resolved under
`/projects/:projectId/ext/:extensionId`, which is why the sample above begins with the page
id. A target outside the current project does not navigate: the owner gets a toast naming
your extension, and the page stays where it was.
Those five calls are the whole of navigation. The host owns the router, which is what keeps a
failing extension from taking the page with it.
## Leaving Tappify
`openExternal` is the only way out, and the host draws the exit itself: a confirmation naming
your extension and the URL, then a new tab. The expand panel's footer carries the same exit,
labelled with your extension's name.
Two other escapes exist for the same reason — your code cannot reach the host's DOM:
```ts theme={null}
import { useTap } from "@tappify/extension-sdk";
export function useExports(summaryText: string, csv: string) {
const tap = useTap();
return {
copy: () => tap.ui.copy(summaryText),
download: () =>
tap.ui.download(new Blob([csv], { type: "text/csv" }), "funnel.csv"),
};
}
```
Both run in the host's own document, so a download from your widget lands where every other
Tappify download lands, and a copy confirms with a toast.
# Call your own server with procedures
Source: https://docs.tappify.ai/extensions/build/procedures
Declare a typed request your UI makes to your own server, relayed by the host.
Your UI never calls your server directly. It calls a declared procedure and the host
relays it, which is what gets you the owner's credentials, no CORS, no server URL in the
browser, and sandbox and production routing without a build flag.
```bash theme={null}
tappify extension add procedure --name getSummary --kind read
```
## The manifest entry
```json theme={null}
{
"server": {
"baseUrl": "https://your-server.example.com",
"procedures": {
"getSummary": {
"input": { "$ref": "./schemas/get-summary.input.json" },
"output": { "$ref": "./schemas/get-summary.output.json" },
"cache": "5m",
"kind": "read"
}
}
}
}
```
The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.
`cache` is the one field the command leaves out: add `"1m"`, `"5m"` or `"1h"` yourself when
repeating the same input over the same window is safe.
`kind: "read"` is retried twice when the connection fails. `kind: "write"` is never retried,
and neither is a call that timed out. A procedure whose name reads like a write — `create`,
`update`, `delete`, `send`, `set` — without `kind: "write"` is a `doctor` warning.
## Calling it
```tsx theme={null}
import { useTapServer } from "@tappify/extension-sdk";
export function Summary() {
const summary = useTapServer("getSummary", { days: 7, platform: "all" });
return {summary.data?.installs ?? 0} ;
}
```
or, outside a component, `await tap.server.getSummary({ days: 7, platform: "all" })`. Both
are typed from the two schemas once you have run
[`tappify extension types`](/extensions/build/generated-types).
## Handling it
```ts theme={null}
import {
TapServerError,
createTappifyHandler,
} from "@tappify/extension-sdk/server";
export const handler = createTappifyHandler({
extensionId: "starter",
procedures: {
getSummary: (request) => {
const { days } = request.input;
if (days < 1) {
throw new TapServerError(
"RANGE_TOO_SHORT",
"getSummary needs a range of at least one day. Widen the date picker, then retry.",
400,
);
}
return { installs: days * 180, delta: 0.08 };
},
},
});
```
`request` carries everything the host knows:
| Field | Is |
| ------------- | ------------------------------------------------------------------------------ |
| `install` | `{ id, extensionId, projectId, organizationId }` |
| `context` | `{ projectId, platform, from, to, filters }` — the page's current pickers |
| `input` | Validated against your declared input schema before it reaches you |
| `credentials` | The owner's credentials, when your manifest declares `connector.auth` |
| `documents` | Your install- and organization-scoped storage, read-only, including `settings` |
| `claims` | The verified install token's claims |
| `eventId` | The idempotency key for this call |
| `request` | The raw `Request`, when you need a header the fields above do not carry |
User-scoped documents are the one thing missing from `documents`: they never leave the host.
## What the host does around it
1. Validates your input against the declared schema.
2. Answers from its own cache when the declaration has a `cache` window and the same install
asked the same thing over the same filters.
3. Mints a five-minute install token and posts to
`POST {server.baseUrl}/tappify/procedures/getSummary` with that token, the credentials,
the documents and the current filters. A sandbox project uses `server.sandboxBaseUrl`
when you declared one.
4. Validates your output against the declared output schema.
5. Caches the result for the declared window.
6. Records the call and its latency on your Runtime page.
Limits: 30 seconds, 1 MB in, 1 MB out, 120 calls per minute per install. A failure arrives
in the UI as `TapServerError { code, message }` carrying your own code and message — the
message is what your component shows, so write one a person can act on.
A procedure is for reads and for your own bookkeeping. Anything that changes the owner's
data in your system is an action, and an action goes through an approval card.
Where the handler above runs, and what to set `server.baseUrl` to.
Drive the handler above with `createTestClient`, the way Tappify calls it.
# Share state between your widgets
Source: https://docs.tappify.ai/extensions/build/share-state
Keep one value across every mount of your extension in the browser, without persisting it.
Every mount of your extension in one browser session — a widget, its expand panel, its tab,
its page — shares one in-memory store. Use it for the things a reload should forget.
```tsx theme={null}
import { TapButton, useTapState } from "@tappify/extension-sdk";
export function Funnel() {
const [step, setStep] = useTapState("selectedStep");
return (
setStep("install")}>
{step ?? "Pick a step"}
);
}
```
`useTapState(key)` returns the current value and a setter, and re-renders every mount that
reads the same key. The same store is on the bridge as `tap.state.get`, `tap.state.set` and
`tap.state.subscribe` when you are outside a component.
## What belongs where
| Keep it in | When |
| -------------------------------------------------- | ------------------------------------------------------------------- |
| `useTapState` | A selection, a filter, an open panel — anything a reload may forget |
| [Hosted storage](/extensions/build/hosted-storage) | A preference or configuration that must come back tomorrow |
| [Your server](/extensions/build/procedures) | Anything that belongs to you rather than to this owner |
The store is scoped to one install in one browser session. It is not shared between
teammates, between browsers or between projects, and it never reaches Tappify or your
server.
Hosted-storage writes also broadcast to every mount, so a preference saved in your settings
panel updates a widget without a reload. That is a different mechanism with the same
symptom: `useTapState` for the ephemeral value, `useTapStorage` for the saved one.
# Style with host tokens
Source: https://docs.tappify.ai/extensions/build/style-with-host-tokens
Inherit the host's palette, type and spacing so your surface renders correctly in light and dark.
Your component mounts in a shadow root. The host's CSS variables inherit into it and your
CSS cannot leak out. Write every colour, radius and font as a variable with a fallback and
your surface follows the owner's theme with no theme code.
```css theme={null}
.funnel-note {
color: var(--fg-muted, rgba(18, 18, 18, 0.6));
border-top: 1px solid var(--divider, rgba(18, 18, 18, 0.12));
border-radius: var(--tap-radius, 6px);
font-family: var(--font-sans, system-ui, sans-serif);
}
```
Import that file from your entry. The build collects the stylesheets each entry reaches and
the host injects them into your shadow root, ahead of your component.
## The variables
| Variable | Is |
| -------------------------------------------- | ---------------------------------------------------- |
| `--bg-base`, `--bg-subtle`, `--bg-raised` | Page, quiet surface, raised surface |
| `--fg-default`, `--fg-muted`, `--fg-inverse` | Body text, secondary text, text on an inverse ground |
| `--tap-accent`, `--tap-accent-fg` | The accent and the text that sits on it |
| `--tap-danger` | Destructive text and borders |
| `--tap-scrim` | The wash behind a dialog or the expand panel |
| `--tap-radius`, `--tap-space` | The corner radius and the base spacing step |
| `--font-sans`, `--font-mono` | The two type families |
| `--divider` | Hairlines |
Those fifteen are the whole list, and they are the same fifteen
`installHostTheme()` sets in a test, so a component renders in a test the way it renders in
the host. Always write a fallback: a variable the host has not set yet falls through to it
rather than to nothing. Today the host sets a handful of these; the rest fall through to your
fallback until the host theme lands.
They change with the owner's theme. `useTapTheme()` gives you `{ mode: "light" | "dark" }`
when a component has to branch rather than restyle — a chart's series colours, say.
## The UI kit already uses them
`TapCard`, `TapStat`, `TapButton`, `TapTable` and the rest of the kit are built on exactly
this list, so a surface assembled from them needs no CSS of your own:
```tsx theme={null}
import { TapCard, TapStat } from "@tappify/extension-sdk";
import "@tappify/extension-sdk/styles.css";
```
## Two rules the checks read
* **No bundled fonts and no third-party stylesheets.** A publish rejects a bundle that loads
a font or a script from another origin. Use `--font-sans` and `--font-mono`. Writing a
stylesheet into the document is a warning rather than a rejection, and the warning is
worth acting on: styles put on `document.head` leave your shadow root and land on the host
and on every other extension on the page. An emitted `.css` asset is a rejection, because
a separate file never reaches a shadow root — the build inlines the stylesheets your entry
imports, so that one does not fire on its own.
* **Container queries, not viewport queries.** A slot's width has nothing to do with the
viewport. Your mount is the container, so size against it:
```css theme={null}
@container (min-width: 480px) {
.funnel-grid {
grid-template-columns: 1fr 1fr;
}
}
```
A widget has to be readable at 320 pixels. That is the width to design against.
# Subscribe to Tappify events
Source: https://docs.tappify.ai/extensions/build/subscribe-to-events
React in your UI when the owner edits your settings, changes your scopes or a release ships.
Tappify publishes events for the things that happen in an owner's project. Subscribe in your
component and the host delivers them, typed, with no polling.
```tsx theme={null}
import { useEffect } from "react";
import { useTap, useTapQuery, useTapFilters } from "@tappify/extension-sdk";
export function Summary() {
const tap = useTap();
const filters = useTapFilters();
const downloads = useTapQuery({
kind: "series",
metric: "downloads",
range: filters.range,
});
const refetch = downloads.refetch;
useEffect(
() =>
tap.data.subscribe("settings.changed", (payload) => {
tap.ui.toast(`Settings updated: ${payload.keys.join(", ")}.`, "neutral");
refetch();
}),
[tap, refetch],
);
return null;
}
```
`subscribe` returns its own unsubscribe function, which is why the effect returns it
directly. `refetch` is stable across renders, so the effect subscribes once rather than on
every render. The event name is typed from the catalogue the SDK ships with, so a name that
does not exist is a compile error; once
[`tappify extension types`](/extensions/build/generated-types) has generated the payloads, a
field that is not on that payload is one too. `settings.changed` is one of the six events
Tappify sends today, so this handler runs the first time an owner saves your panel.
## Refetching without a subscription
When all you want is for a query to reload, name the events instead of writing a handler:
```tsx theme={null}
const summary = useTapServer("getSummary", input, {
invalidateOn: ["settings.changed", "scopes.changed"],
});
```
## The events you can subscribe to
| Event | Fires when | Needs | Sent today |
| ------------------------------------ | -------------------------------------------------------- | --------------------- | ---------- |
| `install.created` | Your extension is installed | — | yes |
| `install.paused` / `install.resumed` | The owner pauses or resumes the install | — | yes |
| `install.revoked` | The owner uninstalls, or you retire the extension | — | yes |
| `install.token_rotated` | The owner rotates the install's token and inbound secret | — | yes |
| `scopes.changed` | The owner grants or removes scopes | — | yes |
| `settings.changed` | The owner saves your settings panel | — | yes |
| `release.shipped` | A release goes live on a store | `projects:read` | yes |
| `metadata.changed` | Store listing metadata changes | `store.metadata:read` | yes |
| `keyword.set_changed` | The tracked keyword set changes | `store.metadata:read` | yes |
| `screenshots.updated` | Screenshots change for a locale | `store.metadata:read` | yes |
| `price.changed` | A price changes in a territory | `store.metadata:read` | yes |
Delivery is filtered by what the owner granted: `price.changed` reaches an install only when
`store.metadata:read` is granted. The type stays the full catalogue either way.
Every payload and every field is on [Events](/extensions/reference/events).
## The same events on your server
The handler routes `POST /tappify/events` for the names your manifest lists under
`server.events`, with the token already verified and `request.event` typed to the payload:
```ts theme={null}
createTappifyHandler({
extensionId: "starter",
events: {
"install.created": (request) => {
void provision(request.install.id, request.event?.grantedScopes ?? []);
},
},
});
```
Tappify posts every name your manifest lists under `server.events` to `POST /tappify/events` as
it happens, one delivery per install. A 5xx or an unreachable server is retried three times over
about twelve minutes, for that install alone; a 4xx is taken as your answer and recorded on the
install's runtime timeline instead. A name your manifest does not list reaches your components
only, through `tap.data.subscribe`.
Adding a field to a payload is free. A renamed field is a new event name, so a handler never
silently reads the wrong thing.
# What is an extension
Source: https://docs.tappify.ai/extensions/index
An extension is a manifest plus a bundle you build and an owner installs on their project.
An extension is a manifest plus a bundle. You build it, an owner installs it on their
project, and it renders inside Tappify's dashboard — the host — next to Tappify's own
numbers. Start with [Your first extension](/extensions/start/your-first-extension) to have
one running in ten minutes.
The manifest, `tappify.extension.json`, declares three things:
| It declares | Which means |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| What you render | Widgets, tabs, pages, row actions and a settings panel, anchored to named host pages and slots |
| What data you move | Read queries against the owner's store data, hosted storage for your own documents, a connector that syncs metrics |
| What the assistant can do | Tools, actions, skills and prompts the owner's assistant can reach once the assistant work ships; the manifest accepts them now |
The bundle is the code behind those declarations: a Module Federation remote that Tappify
serves from its own CDN and mounts in a shadow root on the owner's page.
## What Tappify owns and what you own
Tappify owns the chrome, the session, the tokens, the approval cards and the provenance
mark. You own the pixels inside your mount and the endpoints behind your own server.
Loading, failure and missing-scope states, the vendor tile on every surface you render,
the consent screen, every approval card, and the expand panel's chrome.
Your component's content, its own empty and error states inside the card, and the
responses from your server.
Everything crosses one bridge, `tap`, which the host provides. Your code never touches the
host's DOM, router, cookies or storage, never sees a Tappify session credential or another
extension's data, and never writes to the owner's store without an approval card. The full
list is on [What extensions can never do](/extensions/reference/never).
When your extension fails to load, the host draws a card saying so and the rest of Tappify
carries on. That contract is why the mount is a shadow root and why the loader has an
8-second timeout.
## The pieces you install
| Piece | What it is |
| ------------------------ | -------------------------------------------------------------------------------------- |
| `@tappify/extension-sdk` | The hooks, the UI kit, the manifest schema, the server handler and the testing helpers |
| `tappify` | The CLI that scaffolds, types, serves, checks and publishes |
| Your vendor account | A Tappify workspace with a sandbox project, where unpublished builds run |
One command creates the vendor, the workspace and the sandbox project.
Scaffold, run, see it on a project, publish.
# Publishing
Source: https://docs.tappify.ai/extensions/publish/publishing
Build, check, upload and get a status back — live, or in review.
One command builds your extension, runs the checks, zips the bundle and uploads it. What
comes back is either `live` or `in_review`.
```bash theme={null}
tappify extension publish --notes "Adds the cohort tab"
```
Two lines: what was sent, and what happens next.
```
Sent Starter for review — 0.4 MB.
All checks passed; an admin reviews it, and `tappify extension status` shows progress.
```
| Flag | Does |
| ----------------- | ----------------------------------------------------------------------------------- |
| `--notes ` | What changed in this release, for owners and the reviewer. At most 1,000 characters |
| `--skip-doctor` | Publish without running the checks first |
| `--yes` | Answer every confirmation yes |
## What the command does, in order
Without `--notes` it asks what changed in this release, then names the extension and waits
for a yes before anything is sent. `--notes` and `--yes` answer both, which is what a
script wants.
Runs your `build` script. A failing build publishes nothing, and a missing `dist/` stops
the command before the zip.
Runs [`doctor`](/extensions/test/doctor-checks) without rebuilding, and without your
typecheck, lint, tests or accessibility pass — the build has just run. Any failure stops
the publish and prints the checklist. `--skip-doctor` skips this; the second output line
says which happened.
`tappify.extension.json`, `package.json`, `dist/`, your icon, your screenshots, every
schema a `$ref` names, and any knowledge or skill markdown. At most 5 MB.
Tappify re-runs the manifest and bundle checks against the zip itself, then asks
` /tappify/health` for a 200 within three seconds on every base url your
manifest declares. A redirect counts as a failure.
Live, or in review. The rule is on [Releases](/extensions/publish/releases).
The first publish claims the extension id under your vendor, unless a `dev --live` session
already did, and says so. An id is immutable after that, so a later publish whose manifest
carries a different id is refused rather than creating a second extension. Every other field
is yours to change: a new name, description or category reaches the store card with the
release that goes live.
## What comes back
| Status | Means | First line |
| ----------- | ---------------------------------------------------------------------- | ---------------------------------- |
| `live` | Serving every install now | `Published — .` |
| `in_review` | Waiting on a Tappify reviewer. Your current live release keeps serving | `Sent for review — .` |
An `unlisted` extension comes back `live`: its release is opened for review and approved by
the automated checks in the same request.
```bash theme={null}
tappify extension status
```
It prints the timeline, then the live release, the release in review and the install count in
one line. The Releases page in the developer portal shows the same thing with the diff and
the rejection reason.
Publishes are limited to 30 per day per extension.
Every check, what it looks at, and how to fix it.
When a publish goes straight to live.
# Releases, rolling updates, and release notes
Source: https://docs.tappify.ai/extensions/publish/releases
At most two releases exist at a time. Which one your publish becomes, and when review is involved.
An extension has at most two releases: the `live` one every install is serving, and one
other — waiting on review, withdrawn back to a draft, or rejected. Publishing overwrites
rather than accumulating, so there is no version to pick and nothing to promote. The version
a release is labelled with is your `package.json` version, read out of the bundle.
```bash theme={null}
tappify extension status
```
## What a publish becomes
| Situation | Result |
| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `visibility: "private"` | `live`, always. Private extensions are never reviewed, and the starter ships private, so a scaffolded extension's first publish is live in your own workspace at once |
| `visibility: "unlisted"` | `live`. The release is opened for review and approved by the automated checks in the same request |
| No live release yet, `visibility: "public"` | `in_review`. Every public first publish is read by a person before anyone outside your workspace can install |
| A live release, and the diff adds no scope and no contribution kind | `live`. It replaces the live release now |
| A live release, and the diff adds a scope or a new kind of contribution | `in_review`. The live release keeps serving until a reviewer decides |
| A live release, and the manifest widens visibility — private to unlisted or public, unlisted to public | `in_review`, on the same footing as a new scope |
"A new contribution kind" means a kind the live release does not have — your first widget,
your first action, your first storage collection. A second widget is not a new kind. The
seven assistant blocks count separately, so your first skill is a new kind even when you
already ship tools.
Dropping a scope or a contribution kind never sends a release to review.
## Release notes
```bash theme={null}
tappify extension publish --notes "Adds the cohort tab and drops the unused revenue scope"
```
At most 1,000 characters. Owners read them on the install's page in their dashboard; a
reviewer reads them first. They are stored on the release and on the timeline, so
"what changed in 0.4.0" has an answer after the fact.
## Withdrawing a pending release
A release in review can be pulled back to a draft from the Releases page in the developer
portal. The live release is untouched and the review timeline is kept. Publish again when you
are ready and it re-enters review. Publishing while a release is still in review replaces
that release rather than queueing a second one.
## What owners see
An approved release swaps into `live` and every install picks it up on its next load; the
host re-reads its installs about once a minute while the dashboard is open. When the new
release asks for a scope an owner has not granted, their install renders
```
Funnel Lab needs revenue:read on Photo Editor — the current release asks for a scope you haven't granted on this project.
```
with Review scopes and Keep current, and the rest of their dashboard is unaffected. Owner
admins are told once, when a release that widened the scope set goes live — after a reviewer
approves it, or when the automated checks approve an unlisted one. A private publish sends no
notice, so the card is the only signal there.
A rejected release is marked rejected on the Releases page with the reviewer's reason.
Nothing changes for owners.
# Reporting and quality
Source: https://docs.tappify.ai/extensions/publish/reporting-and-quality
What owners report, what Tappify measures, and where you read both.
The Runtime page in the developer portal is where an extension's behaviour in the field shows
up: who installed it, what synced, what owners approved, what failed to load, what owners
reported, and what ran. Four tiles lead it — installs all time, the load failures you have been
sent, install tokens minted in the last thirty days, and widget views in the last thirty days.
## Installs, by where they came from
| Source | Means |
| ----------------- | ------------------------------------------------------ |
| Tap Store | The owner browsed and installed |
| A direct link | An unlisted link |
| A recipe | A bundle Tappify suggested |
| The assistant | The assistant recommended it in a conversation |
| Connect Tappify | An install started from your own product |
| Your dev sessions | `tappify extension dev --live` on your sandbox project |
The source is recorded when the install is created and never changed, so attribution stays
honest. The breakdown covers the last thirty days; the total above it is every install since
you published.
## Load failures
When your remote fails to load, the host draws a card with Retry and Report to vendor. An
owner who presses Report sends the error, the stack, the page, your release checksum and the
host version — no owner data. Reports land on your Runtime page, newest first, and the
previous day's are summed into a digest email to your vendor admins each morning with a count
and the error you saw most.
The reason is on the card. A bundle that exceeds the 8-second remote entry timeout reads
`remoteEntry.js timed out after 8s`; anything else is the error your code threw as the host
mounted it.
## Owner reports
An owner can report your extension from the install's page with a note. A Tappify admin reads
it, and you are told: the note, and whether it is open, reviewed or dismissed, appears on your
Runtime page, and a notice reaches your vendor admins. A suspended extension stops loading in
every host on its next install refresh, about a minute, and shows a card reading
`Starter is temporarily unavailable. The rest of Tappify is unaffected.`
## Telemetry you declare
```json theme={null}
{ "telemetry": ["summary_viewed", "funnel_expanded"] }
```
```ts theme={null}
tap.telemetry.event("summary_viewed", { size: tap.ui.size });
```
At most 20 `snake_case` names, flat properties, 60 events per minute per install. A name that
is not on the live release's list is refused rather than stored, so declare it and ship a
release before you send it.
The Runtime page's event table counts the last thirty days by event type, which puts every
telemetry event on one row; the per-name breakdown arrives with the metrics below. The
owner's own install page lists that install's recent runtime events.
## Sync health
Every install running your connector, rolled into one line: installs syncing, the newest sync
that succeeded, rows in the last 24 hours, and the slowest install's p95. Under it, the count of
installs that could not reach your server on their last sync — the number to act on. An install
that has never once succeeded still appears there, so a connector that only ever fails is not
reported as "nothing has synced yet".
## Actions
The share of the actions you asked owners to approve in the last thirty days that they approved,
with the two counts behind it. Under 60% flags your listing, so the sentence beside it says so.
## Event replay
The last fifty deliveries across every install, each with the outcome the host recorded — alert
raised, banner shown, recorded, duplicate, rate limited or rejected. Replaying runs the stored
delivery's outcome again; it does not create a second delivery, and a replayed alert still
counts against that project's three alerts a day.
## Store funnel
Listing views in the last thirty days, installs added in that window that are still installed,
and the installs older than thirty days that are still installed. The third number is a count,
not a share of the second.
## The public quality panel
Tappify computes four numbers over the last 90 days and shows them on your listing: server
uptime, assistant tool response time at the 95th percentile, the share of your actions owners
approve, and the number of installs kept after 30 days. A number Tappify has not measured yet
says so rather than showing a zero.
An extension below any threshold — uptime under 99.5%, tool response over 4 seconds, approvals
under 60% — is flagged on its Tap Store card and on its listing, with a line per reason naming
the number that failed.
## What is still coming
One section of the Runtime page says so rather than showing a number: tool calls and response
times arrive with assistant tools.
# Retiring an extension
Source: https://docs.tappify.ai/extensions/publish/retiring
Take an extension out of the Tap Store and off every install, with a window for owners to export.
Retiring removes the extension from the Tap Store and ends every install. Nothing in the CLI
or the portal brings it back.
```bash theme={null}
tappify extension retire
```
The command says what retiring does, then asks you to type the extension id before it
proceeds. `--yes` skips the typed confirmation for a script; without a terminal to ask in,
the command stops and tells you to pass it.
```
Retired Starter; every install now shows the retired notice.
Installs are revoked, and owners can export their documents for 30 days before the data is deleted.
```
## What happens
New installs stop immediately, and so does opening the listing by id — an unlisted link
stops working with it.
Owners see `Starter was retired by its vendor. Your saved documents stay exportable for
30 days.` where your surfaces were. The rest of their dashboard is unaffected.
In their inbox, on every workspace that had an install.
An owner exports the install's hosted-storage documents as JSON from the install's page.
After 30 days the installs and their documents are removed.
A retired extension cannot be published, retired again, relisted, or have its signing secret
rotated. Owners keep the export for thirty days; after that the installs and their documents
are gone, and the only way back into the Tap Store is a new extension under a new id.
## Before you retire
* Publish a final release with notes saying what owners should do instead. They read notes on
the install's page.
* If you only want to stop new installs, set
[visibility](/extensions/publish/visibility) to `private` instead. Existing installs keep
working.
* If you are replacing the extension, publish the replacement first and name it in the notes.
Extension ids are immutable after the first publish, so a replacement is a new extension
with a new id.
# Review and what gets checked
Source: https://docs.tappify.ai/extensions/publish/review
The checks a release has to pass, and what a Tappify reviewer reads on top of them.
[`tappify extension doctor`](/extensions/test/doctor-checks) runs these checks on your
machine, and Tappify runs them again on the bundle you upload, so nothing here should be a
surprise.
```bash theme={null}
tappify extension doctor
```
## What the manifest has to pass
| Check | Looks at | Fix |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `manifest.schema` | The manifest against the published JSON Schema, with every rule the schema adds on top of its field types | Fix the field the message names |
| `manifest.id.reserved` | An id that would shadow a Tappify route | Choose another id; an id is immutable after the first publish |
| `scopes.security_review` | Your vendor's security-review flag, for `ai:actions`, `store.metadata:write`, `autopilot:trigger` and `messaging:send` | Contact Tappify; the flag is set after a review of your company, or drop the scope |
| `exposes` | Every UI contribution has a matching expose in the build | Rebuild; the entry is missing from `dist/` |
| `assistant_text.override` | Tool, action, mention, skill, prompt and context-provider text, for instructions that try to override the assistant's rules | Rewrite the text so it describes your product |
`manifest.schema` is one id carrying the whole schema, so these all arrive under it:
* A widget's slot that is not on its page, and a series naming a metric your connector does
not declare.
* Contributions that imply a scope you did not declare — rendered UI needs `ui:render`,
hosted storage needs `storage:write`, markers need `ui:render` and `alerts:write`, an
action needs `ai:actions` and its own declared scope.
* A scope whose definition asks for a justification, without one of 20 to 500 characters.
* Limits: 20 UI contributions, 15 assistant tools, 10 actions, 10 storage collections,
5 skills, 20 prompts, 3 context providers, 30 procedures, 20 telemetry names.
* A banner or marker naming an event your webhooks do not declare.
* A storage collection called `settings`, which the settings contribution owns.
* A prompt whose `after` names a tool id that is not yours.
* A telemetry name that is not `snake_case`.
* `server.baseUrl` missing when a connector, webhook, assistant contribution, work
destination or procedure exists, and any declared host that is not `https`.
* An `sdk` range naming no major Tappify serves.
* A `public` extension without all five listing fields.
* An `organization`-scoped extension declaring pages, tabs, widgets or series.
* A `pricing` or `score` block, which the schema reserves and does not accept yet.
## What the bundle has to pass
| Check | Looks at |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `bundle.size` | The zip is at most 5 MB. The message also names the limit on what it unpacks to |
| `bundle.manifest` | `tappify.extension.json` is at the root of the zip and parses. `publish` packs it for you, so this one is for a zip built another way |
| `bundle.remote_entry` | `remoteEntry.js` is present |
| `bundle.icon` | Present, png or svg, at most 512 KB, square |
| `bundle.storage_schemas` | Every `$ref` resolves to a file in the bundle, parses, and does not cycle |
| `bundle.listing_assets` | For a public listing: every screenshot is in the bundle at 1600 by 1000 |
| `bundle.third_party_script` | No `