Skip to main content
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, run an example from source:
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: register <TRICK_PUBLIC_URL>/manifest.json, test the connection and enable it.

A trick in one file

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). 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:
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

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.

Signed requests

Pup signs every MCP, challenge and event POST with Standard Webhooks: 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.