> ## 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 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 (
    <TapForm
      schema={sendPushSchema}
      submitLabel="Ask to send"
      onSubmit={async (values) => {
        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.
