# 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 ( ); } 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 `