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.
npm i @butlerbot/sdkYou need Node 18 or newer, and an API key. Browsers work too.
Get an API key
Section titled “Get an API key”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.
Talking to Alfred
Section titled “Talking to Alfred”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.
Pick your linkId once and never change it
Section titled “Pick your linkId once and never change it”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.
Schemas
Section titled “Schemas”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.
Conversations over a Link
Section titled “Conversations over a Link”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.
Scopes
Section titled “Scopes”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.
| Scope | What it allows |
|---|---|
conversations.read | Read conversation history, single chats and usage breakdowns |
conversations.write | Start and continue conversations |
tools.run | Run a tool directly |
usage.read | Read usage totals, policy and logs |
user.read | Read account profile |
user.settings.read | Validate/read account settings |
user.settings.write | Change account settings |
user.functions.write | Enable or disable individual tools for the account |
link.connect | Open a Link websocket connection |
link.tools.register | Expose client-side tools to Alfred over Link |
link.hooks.register | Register 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, souser.*covers all threeuserscopes.*on its own grants everything, which is worth avoiding outside of throwaway local testing. - Any link capability implies
link.connect, so a key scoped tolink.tools.registercan open the socket without you also granting the connect scope. user.settings.writeimpliesuser.settings.read.conversations.writedoes not implyconversations.read. Writing lets a key hold a chat, reading exposes your whole history, and those are deliberately separate grants.
Common combinations:
| What you’re building | Scopes |
|---|---|
| A bot or app that just chats with Alfred | conversations.write |
| The same, but it reads back past chats | conversations.write, conversations.read |
| A link exposing tools | link.tools.register |
| A link that also fires hook events | link.tools.register, link.hooks.register |
| Conversations carried over that link | add conversations.write |
| A usage or billing dashboard of your own | usage.read, user.read |
Scope names are permanent. They’re part of the public API, so a key you scoped today keeps working.
Environment
Section titled “Environment”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.
Common problems
Section titled “Common problems”- 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
linkIdchanged 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
defaultEnabledsays. - 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
jsonSchemaalongside 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.