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

# Build an extension

> Write a trick with the @puphq/tricks SDK: tools, commands, tasks and events.

A trick is a set of tools Pup can use. You host it; Pup handles who may use it,
signs every request, asks for approval when needed and writes every reply in its
own voice. Trick code never runs inside Pup.

Only a verified Discord server
owner can register and install their own trick; there is no public directory.

## Quick start

The SDK and starter packages are not published to npm yet. With private-beta access
to the [Tricks repository](https://github.com/puphq/tricks), run an example from source:

```sh theme={null}
git clone https://github.com/puphq/tricks.git
cd tricks
bun install --frozen-lockfile
cd examples/hello
cp .env.sample .env
bun trick.ts
```

Each example starts in one file, `trick.ts`. Use `examples/snack-club` for a fuller example
with a write tool, a long-running task, a command and buttons. Each project includes
an `AGENTS.md` written for coding agents.

`bun trick.ts` needs `TRICK_PUBLIC_URL`, the HTTPS address Pup reaches it at
(usually a tunnel), and `TRICK_WEBHOOK_SECRETS`, the signing secret Pup shows when
you register. Then follow [the installation guide](https://docs.pup.run/extensions.md): register
`<TRICK_PUBLIC_URL>/manifest.json`, test the connection and enable it.

## A trick in one file

```ts theme={null}
import { defineTrick, serve, tool, z } from "@puphq/tricks"

serve(
  defineTrick({
    id: "snack_club",
    name: "Snack Club",
    author: "Example author",
    description: "Choose a snack and save your preference.",
    summary: "List snacks or save your preference.",
    instructions: "Use save_snack only when asked to save a preference.",
    tools: {
      save_snack: tool({
        title: "Save your snack preference",
        description: "Save the asker's snack preference.",
        input: z.object({ snack: z.enum(["popcorn", "fruit", "pretzels"]) }),
        run: ({ input, askerId, installationId }) => {
          // Store it keyed by installationId and askerId.
          return { saved: input.snack }
        },
      }),
    },
  }),
)
```

The SDK generates the manifest from this definition and serves `/manifest.json`,
`/challenge`, `/mcp` and `/events`. It verifies Pup's signatures, rejects replays,
validates tool input against your zod schema and runs the MCP server. You write
tools; you don't handle the protocol.

### Tools

* `run({ input, askerId, channelId, installationId, settings })` receives validated
  input. Pup has verified who asked, where, and on which server. Never trust IDs that
  arrive as tool arguments; scope stored data by `installationId`, and by `askerId`
  when it is personal.
* Set `userRequest: true` only when a tool needs the original request. Its handler
  then receives `userRequest: { messageId, channelId, content }` from Pup's signed
  context, separate from the assistant-written `input`. It is the triggering
  member's message recorded for that call, not a transcript. Commands and task
  polling do not supply it. Later edits cannot change an existing call's snapshot.
* `callId`, when supplied, is Pup's stable invocation ID. Persist it with
  `installationId` to avoid submitting the same remote work again after a restart.
  It is not an authorization credential.
* **Return data, not messages.** Pup's model reads the result and replies in its own
  words. It never names your trick or relays raw text from it.
* `readOnly: true` marks a tool that changes nothing. Any other tool is a write, and
  Pup may ask the member to approve it first (see [Approvals](#approvals)). Use
  `alwaysAsk: true` for irreversible writes such as deleting or paying.
* `title` appears on Pup's approval card. `description` tells the model when to use
  the tool, so be specific.
* Throw for real failures. Pup tells the member something went wrong without showing
  your error text.

### Commands and buttons

A command runs one tool directly, without the model. Like Pup's own commands, each
works as `/name` and with the server's prefix, for example `^name`. Set
`surfaces: ["slash"]` or `["prefix"]` to offer only one form.

Commands and buttons show the tool's `pup` presentation, so add one to any tool they
use:

```ts theme={null}
return {
  choices,
  pup: {
    fields: [{ label: "Choices", value: choices.join(", ") }],
    actions: [
      { id: "save_fruit", label: "Save fruit", tool: "save_snack", arguments: { snack: "fruit" } },
    ],
  },
}
```

A presentation has optional `text`, `fields`, `links` and up to five `actions`. Each
action is a button that runs its `tool` with fixed `arguments` for whoever clicks it,
after Pup checks their access. A button's result is private to the clicker.

`visibility` decides who sees a `/name` result: `"ephemeral"` (the default) shows it
only to the person who ran it, `"public"` to the channel. A prefix command is typed
in the channel, so its reply is always visible there. Command `options` are flat
`string`, `integer`, `number`, `boolean`, `user`, `channel` or `role` values that
fill the tool argument of the same name. With the prefix, optional values are named:
`^snacks --count=2`.

Typing a command or clicking a button is the member's own decision, like `/ban`: it
runs without an approval card. Pup's permissions decide who may run it.

### Long-running tasks

```ts theme={null}
choose_snack: tool({
  title: "Choose a snack",
  description: "Help the asker pick a snack.",
  readOnly: true,
  run: task(async ({ ask }) => {
    const { taste } = await ask("Sweet or salty?", z.object({ taste: z.enum(["sweet", "salty"]) }))
    return { snack: taste === "sweet" ? "fruit" : "popcorn" }
  }),
}),
```

Wrap `run` in `task()` for work that takes a while or needs the member's input.
`await ask(message, schema)` pauses the task. Pup's model asks the question in its own
words; the member answers normally, by replying or with their next message, and the
answer arrives validated. Keep answer schemas flat: strings, numbers, booleans and
enums. Never ask for passwords, tokens or keys.

Pup waits a few seconds for a task in the same turn, so a quick task answers like any
tool. A longer one continues the conversation when it finishes, asks something, or reports new progress.
Tasks belong in conversations; a command or button that returns a task fails
privately. The SDK keeps task state in memory, so a restart ends running tasks and
Pup tells the member it lost track of them.

For work managed by another service, use `externalTask({ start, get, cancel? })`.
Persist its reference in the trick and return it promptly from `start`; Pup polls
`get` after restarts without submitting the work again. A working state can include
`progress: "The tests passed; checking the build."`. Report meaningful changes,
not every command or unchanged poll. Pup treats progress as untrusted data and
summarizes it in its own voice. Never include secrets or raw command output.

Set `thread: true` on the tool to keep an assistant request and its updates in a
Discord thread. Pup creates one from the triggering message or reuses the current
thread, adds the requester, and passes that thread as `channelId`. The requester
can then talk to Pup there without another mention. A newly created thread inherits
the channel's visibility; it does not make a public request private. Tool audiences
and Discord permissions still apply. Commands and buttons keep their usual behavior.

### Events and settings

`events` handles `member.joined` and `member.left`; return a `pup` presentation to
post in the channel the owner assigned, or nothing.

`settings` declares fields the server owner fills in on Pup's dashboard: `boolean`,
`text`, `long_text`, `number`, `select`, `channel`, `channels`, `role` or `roles`.
Tools receive them as `settings`. There are no secret fields: keep secrets in your
own `.env`.

## Approvals

Pup decides when a person must approve a write; manifests can't change it.

* A write needs approval when it is marked `alwaysAsk`, or when outside content could
  have steered it: a web page, tool output, saved reply, server guidance, or a message
  from someone outside the tool's audience. Incomplete source records also need review.
  Tools available to everyone never escalate. Reads don't need approval.
* The card shows the action and its inputs, with **Do it**, **Auto-review**,
  **Don't** and **Details**. Only the asker can answer, within 15 minutes. **Details**
  is private and shows the complete inputs and excerpts of the outside content.
* **Auto-review** enables automatic review for that exact tool definition: next time, two
  checks run at once. One confirms the action matches what the asker asked for; a
  separate scan looks for injected instructions in the outside content. If both pass,
  the write runs and the reply notes it was auto-reviewed. Anything else shows the
  card. Changing the tool's definition needs new consent, and members can turn Auto
  review off at any time.
* `alwaysAsk` tools always show the card and never offer Auto-review. Writes after
  hosted web search always need the asker.
* **Don't**, or letting the card expire, stops all trick writes for that request.

Automatic progress and completion replies cannot call tools. A new message from the
member can continue the work through the normal permission and approval checks.

## Limits and data

Defaults are 60 requests per installation per minute, and 20 calls per tool per minute.
Pup pauses an installation after five consecutive failures; the owner turns it back on.

Replies may link to exact URLs supplied by the asker or returned in an authorized
trick result. Pup checks known malicious hosts and rejects disguised or credentialed
links. Returned links have no previews and are never automatically opened or probed;
showing one does not verify its destination or grant `fetch_url` access. The assistant
cannot construct a new URL from result data. Asker-only links still pass the existing
destination checks. Pup strips
mass and role pings, and only the asker can be mentioned.

No setting, scope or self-host option lets a trick moderate, change roles, channels or
permissions, send DMs, read other channels in bulk or post anywhere it likes. Pup keeps
full call inputs and results with their source so they can be deleted with it; audit
rows hold identifiers only. Uninstalling removes Pup's copy, not data already sent to
your service, so document your own retention and deletion.

## Protocol reference

Use this if you aren't using the SDK, which implements all of it.

### Manifest

Serve a JSON manifest over HTTPS. Pup validates it against `extensionManifestSchema`
in `packages/extensions/src/manifest.ts` and rejects unknown fields.

```json theme={null}
{
  "manifestVersion": 1,
  "protocolVersion": "2026-07-28",
  "id": "snack_club",
  "name": "Snack Club",
  "author": "Example author",
  "description": "List available snacks.",
  "summary": "Read the available snack choices.",
  "instructions": "Use snack_menu when asked for the available snacks.",
  "endpoints": {
    "challenge": "https://snacks.example.com/challenge",
    "mcp": "https://snacks.example.com/mcp"
  },
  "tools": [{ "name": "snack_menu", "readOnly": true }],
  "commands": [{ "name": "snacks", "description": "See available snacks", "tool": "snack_menu" }],
  "settings": [],
  "events": []
}
```

| Field | Contract |
| - | - |
| `id`, tool `name`, setting `key` | Lowercase letter followed by lowercase letters, digits or underscores; at most 48 characters. Double underscores are reserved. The combined `id__tool` name must fit in 64 characters. |
| `summary` | One line, at most 150 characters; always available to the assistant. |
| `instructions` | At most 2,000 characters; loaded with tool details when needed. Instructions cannot grant access. |
| `tools` | 1–100 unique names matching the MCP tool list. `readOnly` defaults to false; `alwaysAsk` marks an irreversible write; `thread` requests a Discord thread; `userRequest` opts into the original triggering message. MCP annotations don't decide write policy. |
| `commands` | `name`, `description`, `tool`, optional `visibility` (`ephemeral` by default), `surfaces` (both by default) and `options`. Names can't reuse Pup's own commands. |
| `settings` | Up to 25 fields, rendered by Pup. Text defaults to a 500-character cap, long text to 4,000. |

### Signed requests

Pup signs every MCP, challenge and event POST with
[Standard Webhooks](https://www.standardwebhooks.com/): `webhook-id`,
`webhook-timestamp` and `webhook-signature`. Verify the signature over the exact body
bytes, accept a five-minute window, reject replayed MCP and challenge IDs, and accept
either secret during the ten-minute rotation overlap. The challenge endpoint echoes the
signed JSON `challenge` string.

Pup requires HTTPS without URL credentials, pins public IP addresses and rejects
redirects and compressed responses. Each request has a five-second deadline and a
1 MiB response cap; long work returns a Task promptly.

### Invocation context

MCP is stateless `2026-07-28` over Streamable HTTP. Each request carries
`params._meta["io.puphq/context"]` with `installationId`, `askerId`, `channelId`
(absent during tool discovery) and `settings`. Trust it only after verifying the
signature. Recorded calls and their task requests also include `callId`; discovery
does not. A `tools/call` for a tool declaring `userRequest: true` additionally
includes `userRequest: { messageId, channelId, content }` when it originated from
an assistant conversation. Its author is the verified `askerId`; its channel is
the original channel, which can differ from the response thread's `channelId`.
This snapshot is independent of tool arguments. It does not grant more authority
than the member's actual request. Other tools receive no source message. Pup sends
no transcript, role list or Discord credential.

### Tasks

Long tools return an MCP Task (the official Tasks extension). Support `tasks/get` and
`tasks/update`; Pup doesn't cancel tasks. For input, report `input_required` with a
form elicitation; Pup sends the asker's answer with `tasks/update`. A changed
`statusMessage` on a working task resumes the saved conversation with progress
data. Repeated identical status text does not create another update.

### Events

Declare `events` and `endpoints.events`. Pup sends `member.joined` and `member.left`
with the member's ID. The signed envelope has `version`, `id` (equal to `webhook-id`
and stable across retries), `eventId`, `occurredAt`, `event` and `context`. Return
`{}` or `{ "presentation": ... }`. Commit effects once per delivery ID: retries return
the same answer. Pup retries from a durable outbox.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.