Verb

Docs

Adapter tools

A tool marked run: browser is a same-origin fetch and Verb makes it for you. A tool marked run: adapter runs through your own authenticated client instead, because Verb never holds your credentials and never will. You give it one function and it calls that.

One function, one switch statement

Call configure once, wherever your app boots. Verb reads the adapter at the moment a tool is called rather than at start-up, so running late is harmless. Running EARLY is not: the script tag is defer, so code below it in the document still runs before it, and window.Verb is undefined at that point. Wait for it.

"Wherever your app boots" means the same file that already carries the Verb script tag, the one every route in your app renders through, right after that tag. Concretely, by stack:

  • Next.js App Router: app/layout.tsx
  • Next.js Pages Router: pages/_document.tsx
  • Vite or Create React App: index.html
  • Vue or Nuxt: your root component, or nuxt.config.ts's app.head.script
  • Anything else: the shared template or layout every route already goes through

wherever your app boots, once

javascript
function setUpVerb() {
  window.Verb.configure({
    execute: async (tool, args) => {
      switch (tool) {
        case "cancel_order":
          return await supabase.rpc("cancel_order", { p_order_id: args.order_id });
        // one case per adapter tool, from the code block already in verb.md
        default:
          throw new Error(`Unknown tool: ${tool}`);
      }
    },
  });
}

// Both: the event covers a cold load, the check covers a warm cache where
// the script was already there before this ran.
if (window.Verb) setUpVerb();
else window.addEventListener("verb:ready", setUpVerb);

Pass execute and getSessionToken in the same call if you have both, or in two calls: configure merges what you give it, so a later call adding identity will not discard the adapter you set here.

One case per adapter tool, matching the tool names in your verb.md. The code block in each tool's section is what belongs in its case.

Keep it a literal switch even when it gets long, and expect it to get long: thirty tools is a function of a couple of hundred lines. That will trip a maximum function length rule in a strict lint config. Suppress the rule for this function rather than splitting it up or replacing it with a lookup. The switch is your allowlist, and anything that resolves a tool name dynamically lets any string the model produces reach your authenticated client, which is the one thing this shape exists to prevent.

Throw on failure. Do not return an error object

This is the one rule worth getting right, and it is easy to get wrong because both look like they work.

A thrown error is recorded as a failed call and the assistant says so. A returned { error: ... } is recorded as a successful call that happened to come back with unusual data, and the model, doing its best with what it was handed, will often summarise the turn as done.

That is the failure the whole product exists to prevent, arriving through a door the rest of the design does not cover. If your client returns errors rather than throwing, as the Supabase client does, check the error field yourself and throw.
javascript
case "cancel_order": {
  const { data, error } = await supabase.rpc("cancel_order", {
    p_order_id: args.order_id,
  });
  if (error) throw new Error(error.message); // not: return { error }
  return data;
}

The default case matters too

Throw on an unknown tool name rather than falling through silently. The name arrives from a model, and a switch that quietly returns undefined for something it does not recognise reports a successful call that did nothing at all.

If you skip this

Nothing breaks quietly. Adapter tools still import and still activate, and every call fails with a clear "needs an adapter" error naming the tool. You will know.

A worked example, in public

Verb's own dashboard is an adapter site: the assistant at app.askverb.com runs four tools through this exact mechanism against our own API. Three rules from building it are worth stealing:

  • The session decides the account, never an argument. There is deliberately no organisation id parameter, because a tool that takes one can be talked into naming a different one.
  • An unknown action is a 404, not a pass-through. Forwarding a model-supplied name as a path fragment hands the model your whole API.
  • Throw, do not return. The rule above, learned here first.

Stuck on something this does not cover? Write to us and you reach the person who built it.