SDK
The ButlerBot SDK is a JavaScript library for talking to Alfred from your own code. It covers five things: holding a conversation, giving Alfred tools that run on your machine, firing events that wake your automation, following the jobs he runs for you, and asking his judge to decide something for your own code.
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.
Or ask Alfred to build it
Section titled “Or ask Alfred to build it”You don’t have to write any of this yourself. Ask Alfred for the thing — “make me an app I can run so you can reach my PC”, “when my PC turns on, send me the news”, “connect to the lights in my flat” — and he writes it against this SDK, makes it a key of its own, builds it, and hands you a file to run, then checks that it connected. Keys he makes show up on your API Keys page with an Alfred badge and the name of the app, and they hold only the scopes that app uses; revoke one there and the app stops.
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.
- Agents: a worker of your own, with a prompt and tools only it can see, that you can also talk to directly.
- 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.
Agents
Section titled “Agents”When your client has more tools than a chat should see, or tools that only make sense together, declare an agent. It lives on the server for as long as the link does, works with tools that run on your machine, and to Alfred it’s one tool:
import { Agent, Tool } from "@butlerbot/sdk";
const barista = new Agent({ id: "barista", name: "Barista", description: "Runs the kitchen coffee machine: grinding, brewing, cleaning.", prompt: "You operate a coffee machine. Always grind before you brew. Report what you made.", model: "DeepSeek-V4-Flash", tools: [grind, brew, clean],});link.addAgent(barista);The tools come with the agent. They don’t need an addTool of their own, and Alfred never sees them directly: ask him for a coffee and he hands the job to the Barista, which decides which of its tools to call. The prompt is where a thousand tools become one coherent worker, and the description is what Alfred reads to decide when to delegate.
You can talk to the agent yourself. The run happens on the server, on your account; the question and the answer live in your code:
const { text } = await barista.chat("Make me a flat white.", { onStatus: (status) => console.log(status.label),});One Agent remembers across chat calls. Pass { thread: "kitchen" } to name the conversation yourself, which is how you pick one up across restarts, or call newThread() to start over.
That’s what makes a hook callback useful on its own. Something happens, your code notices, and you ask an agent what to do about it, all without a conversation with Alfred in between.
A model your plan doesn’t include falls back to your default, exactly as it would in a chat. An unknown model name is rejected at registration. Declaring agents needs link.tools.register; talking to one directly needs tools.run.
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.
A job is long-running work Alfred does on his own, planned and worked in short bursts over hours or days. The SDK reads jobs and steers them; it doesn’t run them. A job starts the way every other one does, by asking Alfred for it.
const { jobs, allowance, spentTodayUsd } = await client.listJobs({ status: "running", page: 1, limit: 20 });const { job, plan, journal, journalTotal, openDeliveries } = await client.getJob({ jobId });const { entries, hasMore } = await client.getJobJournal({ jobId, page: 2 });getJob carries the newest page of the journal along with journalTotal, and getJobJournal pages back through the rest, newest page first. Every entry says which shift wrote it and on which model, which is what the Dashboard’s timeline is drawn from.
Paging is 1-based everywhere, and every listing answers with page, limit and total. A listing also carries allowance, which is what your jobs may spend today and where that number comes from, or a refusal with a message when your plan doesn’t include jobs.
await client.updateJob({ jobId, title: "Flat hunt", autonomy: "free", autonomyUntil: Date.now() + 86_400_000 });await client.cancelJob({ jobId });await client.resumeJob({ jobId });autonomy is how far a job may act outward without asking, ask, free or auto. autonomyUntil is when a loosened setting lapses back to asking, and alwaysAsk is what the job asks about however free it otherwise is. effectiveAutonomy on a job is the one that actually applies, with the account default and any lapse already worked in.
resumeJob is the Continue button on a job that’s out of budget: it checks your plan and your allowance again and answers { resumed, reason, action, wakeAt, message, job }, so a job it still can’t start says what would change that and roughly when it wakes by itself. A job in any other status is a 409.
What new jobs start with, and what all of them may spend in a day, is your own setting:
await client.updateJobSettings({ allowanceUsd: 5, autonomy: "ask", alwaysAsk: ["spending money"] });Reading needs jobs.read, and anything that changes a job needs jobs.write.
Outreach
Section titled “Outreach”Outreach is what Alfred has told or asked you outside a chat. The record is the inbox, so a question a job is parked on is a delivery with intent: "question" and no answer on it yet.
const { deliveries } = await client.listDeliveries({ page: 1, limit: 20 });const { delivery, job } = await client.answerDelivery({ deliveryId, text: "The one in Gardens" });Answering closes the delivery, and when it belongs to a job the answer goes onto that job’s inbox and wakes a job that was parked waiting for it. job.delivered says what became of it. An approval takes decision: "approve" | "deny" alongside the text, which is the same thing the Approve and Deny buttons in the Dashboard send.
Listing needs outreach.read, answering needs outreach.write.
The judge is how Alfred makes a yes/no, pick-one or score decision without a conversation: a decision model reads facts you state and answers each question with a label and probabilities. It’s what his jobs and reflexes decide with, and your code can ask it too. It answers in well under a second for a fraction of a cent, so an app can ask it about every message that comes past.
const { answers, costUsd } = await client.judge({ state: { message: text, channel: "general", rules }, questions: { hostile: { type: "boolean", instructions: "Is the message hostile?", criteria: { true: "It attacks or insults someone", false: "It is civil, however blunt" }, }, rule: { type: "choice", instructions: "Which server rule does the message break?", criteria: { spam: "Promotional or repeated", abuse: "Insults a person", none: "No rule is broken" }, }, },});
if (answers.hostile.value) warn(answers.rule.choice);Every question is judged against the same state in one call. A boolean answers with value and probability, a choice with choice (one of its own criteria keys), confidence and every label’s probability, and a score with level, score and confidence.
It’s a decision model, not a chat model. It writes no text and does no reasoning, so state facts rather than a transcript, work out numbers and dates in code, describe both sides of a boolean, and add a none label to a choice when nothing may fit. A question it can’t ask is refused with a message saying why, and a decision it couldn’t reach is an error, never a guess.
Use it for a decision your app makes itself. If you want Alfred to react to something, don’t judge first: report it as a hook event and let the user’s reflex decide, with the judge inside it if it needs one. That keeps what Alfred reacts to, and what it costs, in the user’s hands.
Needs judge.run.
When a jobs, outreach or judge call fails
Section titled “When a jobs, outreach or judge call fails”These calls throw ButlerBotAPIError when the server doesn’t answer with a success. The status is on the error, so the cases worth branching on are told apart without reading a message, and the parsed body is kept: a rejected cancel still carries the job, a rejected answer still carries the delivery.
import { ButlerBotAPIError } from "@butlerbot/sdk";
try { await client.cancelJob({ jobId });} catch (error) { if (error instanceof ButlerBotAPIError && error.isConflict) { // 409: it had already finished. error.body.job is how it settled. } else throw error;}isNotFound (404), isConflict (409) and isBadRequest (400) are the three. A job or delivery that isn’t this key’s is answered exactly as one that doesn’t exist, so a 404 means either.
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, including talking to your own agents over a Link |
judge.run | Ask the judge: a yes/no, pick-one or score decision on facts you state |
jobs.read | Read jobs, their plans and their journals |
jobs.write | Stop and steer jobs, and set what they may do unattended |
outreach.read | Read what Alfred has asked or told you outside a chat |
outreach.write | Answer what Alfred asked outside a chat |
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 and agents 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.jobs.writeimpliesjobs.read, andoutreach.writeimpliesoutreach.read. A key that can stop a job and not see what it’s doing can only act blind, and answering a question is reading it and replying in one motion.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 or agents | link.tools.register |
| The same, talking to its own agents | link.tools.register, tools.run |
| A link that also fires hook events | link.tools.register, link.hooks.register |
| Conversations carried over that link | add conversations.write |
| A jobs dashboard of your own | jobs.write, outreach.write |
| A bot that asks the judge about every message | judge.run |
| 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.