Skip to content

SDK

The ButlerBot SDK is a JavaScript library for talking to Alfred from your own code. It covers three things: holding a conversation, giving Alfred tools that run on your machine, and firing events that wake your automation.

It’s for you if you want Alfred inside something you’re building, or you want him able to reach something only your machine can reach.

Terminal window
npm i @butlerbot/sdk

You need Node 18 or newer, and an API key. Browsers work too.

Open the Dashboard, then the account menu, then API Keys. Create a key, pick its scopes, and copy the secret.

The secret is shown once, at creation. If you lose it, delete the key and make a new one. Treat it like a password: it acts as you, so keep it out of your repo and in an environment variable.

A conversation is the same thing you get in the Dashboard, just driven by your code. It’s persisted on your account, so history, modules, Brain and your profile all apply exactly as they do in chat.

import { ButlerBotClient } from "@butlerbot/sdk";
const client = new ButlerBotClient({ apiKey: process.env.BUTLER_API_KEY });
const convo = client.createConversation();
const { text } = await convo.ask("Hey there Alfred!");

ask waits for the answer. If you’d rather see everything as it happens, including his live status updates, use send with a callback instead:

convo.send("Hey there Alfred!", (res) => {
if (!res.success) return;
const { type, payload } = res.data.response;
console.log(type, payload);
});

Needs conversations.write, plus conversations.read if you want to read history back.

A Link is a live connection between your code and Alfred. It does three things:

  • Tools: Alfred calls code that runs on your machine.
  • Hooks: your code wakes background agents when something happens.
  • Conversations: turns travel over the connection you already have instead of a new HTTP stream each time.
import { ButlerBotClient, Tool, Hook } from "@butlerbot/sdk";
import { z } from "zod";
const client = new ButlerBotClient({ apiKey: process.env.BUTLER_API_KEY });
const link = client.createLink({ linkId: "coffee-machine" });
link.addTool(new Tool({
id: "brew",
description: "Brew a coffee for the user",
schema: z.object({ cups: z.number().int().min(1).max(4) }),
run: async ({ args, status }) => {
status.update("Grinding beans");
status.complete("Brewed the coffee");
return `Brewed ${args.cups} cup(s).`;
},
}));
await link.connect();

That’s a real tool. Ask Alfred to brew you a coffee in the Dashboard and this code runs, on your machine, wherever it happens to be.

Every id the link creates is built from linkId, so the tool above is link:coffee-machine/brew. Those ids are what the user’s saved tool settings and background agent subscriptions point at, so changing the linkId orphans both without any error to tell you.

Pick a deliberate constant. Never a hostname, a version number, or anything generated at startup.

Nothing else is stored on either side. The server keeps no record of a link between connections and the SDK re-declares everything when it connects, so a link can move machines and land on the same settings.

Two live connections with the same linkId is last writer wins. The newer one takes over and the older one’s registrations are released. That’s deliberate, so a half-dead socket can’t lock out a fresh one during a deploy, but it does mean two genuinely different clients must never share an id.

Tools belong to the user, not to a conversation

Section titled “Tools belong to the user, not to a conversation”

Once a tool is registered, Alfred can call it anywhere that user talks to him, Discord and the Dashboard included, not just conversations your code started. defaultEnabled decides whether it’s on before the user has touched it, and after that their own setting wins.

If your client mirrors a whole platform rather than adding one ability, put the tools behind an agent instead:

new Tool({
id: "discord_member_kick",
description: "Kick a member from a server.",
platforms: ["platform.agent.discord"],
run: async ({ args, meta }) => kick(meta.identities?.discord, args),
});

Seventy tools in front of somebody asking about their groceries is not a feature. Behind Alfred’s Discord agent they’re one entry that already knows when Discord is relevant, and the agent keeps whatever tier and permission gating it carries. An unknown platform is rejected at registration rather than ignored, because a tool reachable from nowhere looks exactly like a broken one.

schema takes a zod 4 schema, any Standard Schema, or a plain JSON Schema object. The SDK doesn’t depend on any of them.

If the schema can validate, arguments are checked before your tool runs and args is typed from it. The server deliberately doesn’t check them for you: you wrote the schema, so you own the check. With zod 3, pass jsonSchema alongside schema, since zod 3 can’t produce JSON Schema on its own.

A hook is an event source your code owns. It’s the trigger side of automation: the user points a reflex at your hook, and your event is what wakes it.

const waterLow = new Hook({
id: "water-low",
name: "Water tank low",
description: "Fires when the water tank drops below a quarter full",
events: [{ name: "low", description: "The tank needs refilling" }],
});
link.addHook(waterLow);
await link.connect();
await waterLow.emit("low", { level: 0.2 });

emit hands the event to the server and lets it work out who cares. That’s fine for a source that fires rarely, like a water tank or a build finishing.

For a busy source, use report instead. The server pushes down the list of things it wants watched, your client matches locally, and only the subscriptions that matched are sent:

link.on("subscriptions", (subscriptions) => {
for (const subscription of subscriptions) {
console.log(subscription.name, subscription.prefilter, subscription.identities);
}
});
const matched = await doorbell.report("rang", { camera: "front" });

Why that’s the better path:

  • Nothing irrelevant is sent. A channel with 10,000 messages a day that nobody subscribed to costs one local comparison per message and zero frames.
  • Your platform’s semantics stay in your code. “Messages in #support from non-bots” is knowledge Alfred’s server never has to learn.
  • Fan-out is one frame. Five people watching one channel is one event with five ids.
  • You never name a user. A subscription id is a handle the server issued and already bound to an owner, so ownership isn’t something your client can get wrong or forge.

subscription.prefilter is applied for you by report. It’s a dot-path map of conditions, all ANDed, scalars or arrays like { "author.bot": false, "channel.id": ["1", "2"] }. It’s a volume gate, not a query language. Ignoring prefilters is still correct, just louder, because the server evaluates them again before spending anything.

When the condition isn’t field equality, like “mentions my user” or “within 50 metres”, decide it with real code and use reportTo:

const mine = doorbell.subscriptions.filter(
(s) => s.identities?.discord && message.mentions.users.has(s.identities.discord),
);
await doorbell.reportTo(mine.map((s) => s.subscriptionId), "rang", payload);

reportTo skips the prefilter, since you already decided. It does drop any id this link isn’t currently holding, so a subscription that disappeared between your decision and the call is a dropped report rather than a rejected frame.

subscription.identities is how you answer “is this event about my user”. It’s a plain string map in namespaces you understand, like { discord: "1897..." }, and it’s only present for owners who linked that account. Nothing else about the user is exposed.

Subscriptions are never persisted by the SDK. They arrive on connect, follow changes while connected, and are dropped on disconnect, so a restart is correct by construction and there’s nothing to reconcile.

Pass a connected link as the transport. Everything else is identical, same methods and same payloads, so nothing that consumes a conversation needs to change:

const convo = client.createConversation({ transport: link });
const overHttp = client.createConversation();

Which to use:

  • HTTP is the simplest thing that works and has no connection to manage. Best for a one-off request, a serverless function, or a page that just wants an answer.
  • A Link reuses a connection you already have and avoids a new HTTP stream per turn. Best when you’re already running a link for tools or hooks, or holding many conversations at once, since one socket carries them all.

Two differences worth knowing. Link sessions are ephemeral, so if the connection drops mid-turn the SDK reopens the session and resends for you, and the conversation itself is stored server-side so nothing is lost. The HTTP transport replays your own message back to you, which exists so a browser reconnecting mid-turn sees it, while a link doesn’t because it has nothing to replay.

Neither transport can cancel a turn. close() stops delivery on your side, and the reply is still generated and stored.

Every API key carries a list of scopes, and each scope is one thing the key is allowed to do. A key with no scope for an action is refused, so grant the narrowest set that does the job. A key that only runs a link for tools should not be able to read your entire chat history.

ScopeWhat it allows
conversations.readRead conversation history, single chats and usage breakdowns
conversations.writeStart and continue conversations
tools.runRun a tool directly
usage.readRead usage totals, policy and logs
user.readRead account profile
user.settings.readValidate/read account settings
user.settings.writeChange account settings
user.functions.writeEnable or disable individual tools for the account
link.connectOpen a Link websocket connection
link.tools.registerExpose client-side tools to Alfred over Link
link.hooks.registerRegister hook sources and emit hook events over Link

A few rules on top of the list:

  • A trailing * is a wildcard for everything below it, so user.* covers all three user scopes. * on its own grants everything, which is worth avoiding outside of throwaway local testing.
  • Any link capability implies link.connect, so a key scoped to link.tools.register can open the socket without you also granting the connect scope.
  • user.settings.write implies user.settings.read.
  • conversations.write does not imply conversations.read. Writing lets a key hold a chat, reading exposes your whole history, and those are deliberately separate grants.

Common combinations:

What you’re buildingScopes
A bot or app that just chats with Alfredconversations.write
The same, but it reads back past chatsconversations.write, conversations.read
A link exposing toolslink.tools.register
A link that also fires hook eventslink.tools.register, link.hooks.register
Conversations carried over that linkadd conversations.write
A usage or billing dashboard of your ownusage.read, user.read

Scope names are permanent. They’re part of the public API, so a key you scoped today keeps working.

Node 18 or newer. Node 22 and every browser have a built-in WebSocket. On older Node, install ws or pass your own socketFactory.

Browsers are supported. A websocket handshake can’t carry headers there, so the SDK sends the service and credential as subprotocols rather than putting your key in the URL.

  • Rejected at the handshake. The key is missing a link.* scope. Check the scopes on the key rather than the code, since a key with no link scope validates fine everywhere else and only fails when it connects.
  • Your tool vanished from Alfred’s list. Either the link isn’t connected, or the linkId changed and the old settings are pointing at ids nobody registers anymore. Check the id first.
  • The tool is registered but Alfred never calls it. The user has it switched off. Tools follow the user’s own setting once they’ve touched it, whatever defaultEnabled says.
  • A newer connection took over. Two processes are sharing a linkId. Give each one its own.
  • Hook events fire but nothing happens. A hook only does something once the user has a reflex pointed at it. See Automation.
  • Arguments arrive wrong or unvalidated. The server doesn’t validate for you. Use a schema that can actually validate, and on zod 3 pass jsonSchema alongside it.

Everything the SDK does still counts against your allowance and your plan’s model access, exactly like chatting in the Dashboard does. See Usage.