> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tappify.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 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 `<vendor_content kind="tool_result">` 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.
