Tools

A tool is a function a route can call. Tools live in a route's tools/ directory (or the shared src/tools/), and B4.run finds them for you. B4.run infers each tool's input and output types from the TypeScript source, so you don't write Zod schemas for tools. (Route state in state.ts does use Zod. See State.)

A minimal toolCopy link to section: A minimal tool

A tool is one file with a default-exported function. The JSDoc comment above the export becomes the description the model reads when it decides whether to call the tool:

src/app/(public)/hello/[tenant]/tools/greet.ts
/** Greet the tenant organization by name. */
export default async (input: { readonly tenant: string }) => {
  return { greeting: `Hello, ${input.tenant}!` }
}

That's it. At build time, B4.run reads the input and output types with the TypeScript compiler API and writes them to .b4/b4.generated.d.ts. In an agent route, b4 build wires the tool in for the model to call. In a workflow, or a callable graph function that receives B4.run's RuntimeContext, you call it as ctx.tools.greet. A precompiled raw LangGraph object works differently (see Invoking a tool).

Shared toolsCopy link to section: Shared tools

Tools come from two places. Each route has its own tools/ directory, and the shared src/tools/ directory holds tools that several routes use:

text
src/
├── tools/                  ← shared across every route
│   ├── lookupOrder.ts
│   └── escalate.ts
└── app/
    └── support/
        ├── index.ts
        └── tools/          ← route-local to /support
            └── escalate.ts

B4.run merges both sets, for agent routes and for the typed ctx.tools that workflow and callable-graph functions get. When both define the same tool name, the route-local tool wins. In the tree above, /support sees its own escalate and the shared lookupOrder.

Put a tool in src/tools/ when more than one route needs it. Keep it in the route's tools/ when only that route uses it, or when it overrides a shared tool.

Top-level agents and subagents see different sets:

Tool sourceTop-level agent routeSubagent route
Shared authored src/tools/*Available by defaultAvailable by default
The route's own local tools/*Available by defaultAvailable by default
A parent route's local tools/*Not applicableNot inherited
Active capability toolsAvailable by defaultWithheld unless named in tools.allow

Scoping a route's toolsCopy link to section: Scoping a route's tools

A top-level agent sees every tool available to it by default: its own tools/*.ts, the shared src/tools/, and capability tools such as writeFile, runBash, writeTodos, readSkill, and remember/recall. To narrow that set for one route, pass tools: { allow, deny } to agent():

  • deny removes a tool, so the model never sees it.
  • allow adds a withheld capability tool back.
  • deny wins when a name appears in both.
  • Denying writeFile also withholds editFile, since both write files. Name editFile in allow to keep it: deny: ["writeFile"], allow: ["editFile"] lets the model change existing files but not create new ones. It doesn't work the other way: an allow list that names writeFile doesn't grant editFile, so list each tool you want.
  • Leave out tools to keep the default set above.

Here's a route that gets everything except the shell:

src/app/navlog/index.ts
// src/app/navlog/index.ts: top route, everything except shell
export default agent({ model: "gpt-5", systemPrompt: "…", tools: { deny: ["runBash"] } })

B4.run applies the scope when it composes the route. A withheld tool never makes it into the generated entry, so the model can't call it. b4 check validates the names you list, and a name that isn't in the route's available set is a build-time error.

Subagents and capability toolsCopy link to section: Subagents and capability tools

A subagent gets the shared src/tools/* plus its own route-local tools/*.ts. The parent's local tools stay with the parent. Capability tools such as writeFile, runBash, writeTodos, and remember/recall are withheld unless you name them in allow.

Since shared tools reach children by default, deny any sensitive one a child shouldn't have:

src/app/navlog/subagents/performance/index.ts
// deployProd is authored in src/tools/, so deny it explicitly for this child.
export default agent({
  model: "gpt-5-mini",
  systemPrompt: "…",
  tools: { allow: ["readFile"], deny: ["deployProd"] },
})

The internal task mechanismCopy link to section: The internal task mechanism

B4.run adds a task tool when a route has at least one child it can dispatch to. task is internal, so tool scoping doesn't apply to it. A nested subagent with its own child gets task too, without any entry in tools.allow.

Leave task out of tools.allow, tools.deny, tools.approve, and tools.constrain. Any of those reports B4_E1004 during b4 check and route preparation. To control dispatch, use the parent's delegation policy.

Requiring approval per callCopy link to section: Requiring approval per call

approve is the third tools option. A tool named in approve (your own or a capability tool) asks a person before each call:

src/app/ops/index.ts
export default agent({
  model: "gpt-5",
  systemPrompt: "…",
  tools: { deny: ["runBash"], approve: ["deployProd", "sendEmail"] },
})

The prompt shows the call's arguments as a JSON preview, but the decision applies to the tool name:

  • Once: this call runs. The next call to the same tool asks again.
  • Always: saves the tool name to the permissions store, so later calls run without asking. The default Node store is .b4/permissions.json. Custom stores, such as Postgres, save it elsewhere.
  • Deny: blocks the call. The model gets the denial reason as the tool result.

To ask on every call with no Always answer, write the entry as { tool: "sendEmail", allowAlways: false }. The prompt then offers only Once and Deny, and an allow rule for the tool is ignored. See Approve every call.

See Permissions for the full interrupt payload and resume flow.

Constraining argumentsCopy link to section: Constraining arguments

constrain is the fourth option. It's a function per tool that checks the model's arguments on each call. Return true to allow the call, a string to deny it (the model gets the string as the tool result), or { approve: true } to ask a person through the approval prompt:

src/app/ops/index.ts
export default agent({
  model: "gpt-5",
  systemPrompt: "…",
  tools: {
    constrain: {
      deployProd: (args, ctx) => {
        const { env } = args as { env?: string }             // args is typed `unknown`
        if (env === "prod") return { approve: true }         // human-in-the-loop
        if (env === "staging") return true                   // allow
        return `Unknown environment "${env}".`               // deny, model sees this
      },
    },
  },
})

The function receives the parsed args and a read-only ctx ({ toolName, routeId, threadId?, signal, params? }), and it can be async. If it throws or returns anything else, the call fails closed and is denied. b4 check validates the tool names but not the function bodies.

Don't list a tool in both approve and constrain. constrain wins (it can escalate with { approve }), and b4 check warns.

The runtime signatureCopy link to section: The runtime signature

A tool's full signature is (input, ctx) => ..., where ctx is a B4ToolContext from @b4run/sdk. It carries signal, threadId?, middleware? and fs:

src/app/(public)/hello/[tenant]/tools/greet.ts
import type { B4ToolContext } from "@b4run/sdk"
 
export default async (
  input: { readonly tenant: string },
  ctx: B4ToolContext,
) => {
  // Cooperate with cancellation. The AbortSignal is always present.
  const res = await fetch(`https://example.com/hello?tenant=${input.tenant}`, {
    signal: ctx.signal,
  })
  // ctx.middleware is the readonly bag populated by allow({ ... }) in src/middleware.ts.
  // ctx.fs is a sandboxed WorkspaceFs handle for reading and writing workspace files.
  const notes = await ctx.fs.readFile("notes.txt").catch(() => "")
  return { greeting: await res.text(), notes }
}

The second parameter is optional. Add it when a tool calls the network, runs a long time, needs middleware context, or reads and writes workspace files. See Middleware for how defineMiddleware and allow(context) fill in ctx.middleware, and Workspace Filesystem for ctx.fs.

ctx.threadId is the current thread's ID. The runtime sets it (the model can't), and it's absent outside a thread, so handle that case. Of course, a thread ID only identifies a thread. It says nothing about who the user is or what they may access, so keep your own access checks and sandbox isolation.

Tool descriptionsCopy link to section: Tool descriptions

In an agent route, the model reads each tool's description to decide when to call it. The description comes from the JSDoc comment directly above the default export:

src/app/(public)/hello/[tenant]/tools/greet.ts
/** Greet the tenant organization by name. */
export default async (input: { readonly tenant: string }) => {
  return { greeting: `Hello, ${input.tenant}!` }
}

b4 typegen copies the comment into the route's tools.json manifest. To set it in code, export a description string. It takes priority over the JSDoc comment:

src/app/(public)/hello/[tenant]/tools/greet.ts
export const description = "Greet the tenant organization by name."
 
export default async (input: { readonly tenant: string }) => {
  return { greeting: `Hello, ${input.tenant}!` }
}

A tool without a description still works, but the model only sees its name. I recommend writing the one-liner.

Describing a call to peopleCopy link to section: Describing a call to people

The model reads description; a person reads the step a call becomes in the chat. Export display to say how a call reads while it runs and once it returns:

src/tools/searchCorpus.ts
import type { ToolDisplay } from "@b4run/sdk"
 
export const display = {
  icon: "search",
  running: ({ query }) => `Searching the corpus for “${query}”`,
  done: ({ query }) => `Searched the corpus for “${query}”`,
  sources: (hits) => hits.map((hit) => ({ title: hit.path })),
} satisfies ToolDisplay<{ query: string }, { path: string }[]>
 
export default async (input: { readonly query: string }) => { /* … */ }

Every field is optional. icon is one of search, read, write, run, web, memory, plan, agent, think or tool. output in done and sources is the value the tool returned, exactly as returned. The runtime evaluates running as the call starts and done and sources as it returns, truncates labels at 120 characters, and streams the result to AG-UI clients as a b4.step event (see AG-UI). A label that throws, or returns something other than a string, number or boolean, falls back to the default and is logged once. A call blocked by tools.approve or tools.constrain never reaches done or sources: the model gets the denial reason as the result, and the step settles as denied with the icon alone. Without display, no b4.step is sent for the call; B4.run's own chat components then fall back to "Using searchCorpus…" and "Used searchCorpus". The built-in workspace, memory, skill, plan and subagent tools ship their own labels.

A call parked for approval keeps its running label on the interrupt, so a reloaded chat shows it and titles the approval card with it ("The agent wants to …"), as the live run did; phrase running as an infinitive ("File the flight plan") when the tool needs approval. B4.run's chat components merge two or more consecutive finished calls of one tool into one row. When the done labels share their leading words and end in a short name, the row lists the names: "Looked up KSTP" and "Looked up KRST" merge as "Looked up KSTP and KRST". A trailing parenthetical is left out, so "Looked up KSTP (St Paul Downtown)" still merges. Otherwise the row reads "Used searchCorpus 2 times", unless the app words it with a group label in the labels prop (see AG-UI).

b4 check rejects a display with an unknown icon, a non-function label or an unknown key (B4_E5002).

Ending the run from a toolCopy link to section: Ending the run from a tool

By default a tool's result goes back to the model for another turn, and the run ends only when the model answers without calling a tool. When a tool's result is the answer, that extra turn buys nothing: the model spends tokens and seconds saying what the tool already did. Export returnDirect to end the run on the tool's successful result instead:

src/app/(public)/assistant/tools/render.ts
export const returnDirect = true
 
const COMPONENTS = new Set(["Table", "Chart", "Summary"])
 
/** Show the composed answer to the user. Call exactly once, last. */
export default async (input: { readonly ui: readonly string[] }) => {
  const unknown = input.ui.filter((name) => !COMPONENTS.has(name))
  if (unknown.length > 0) throw new Error(`invalid_ui: unknown components ${unknown.join(", ")}`)
  return { rendered: true }
}

The run's final state then carries no closing assistant message: the last message is the tool result, the AG-UI stream ends after TOOL_CALL_RESULT with RUN_FINISHED, and a middleware after hook runs with an empty final message. Reserve it for a tool the model is instructed to call last; a returnDirect tool called mid-plan ends the run there.

Only a successful call ends the run. When a returnDirect tool throws, or the model's arguments fail the tool's schema, the error goes back to the model like any other tool error, so the model can correct the call and try again; the first call that succeeds ends the run. That makes returnDirect a good fit for a tool that validates the model's output, like the renderer above.

Invoking a toolCopy link to section: Invoking a tool

In a workflow, call a tool through ctx.tools.<name>(...):

src/app/(public)/hello/[tenant]/index.ts
// workflow form
import type { RuntimeContext } from "@b4run/sdk"
import type { RouteTools } from "b4:routes"
import state from "./state.js"
 
export async function workflow(
  input: unknown,
  ctx: RuntimeContext<RouteTools<"/hello/[tenant]">>,
) {
  const parsed = state.parse(input)
  const result = await ctx.tools.greet({ tenant: parsed.tenant })
  return { ...parsed, greeting: result.greeting }
}

ctx.tools.greet has full IntelliSense for its input and return shapes.

A callable graph function can accept the same B4.run RuntimeContext and use ctx.tools. A precompiled raw LangGraph object works differently. Its .invoke() treats the second argument as a LangGraph RunnableConfig, so it never sees ctx.tools. That graph uses the tools its code already owns or imports.

In an agent route, the model calls the tools. b4 build wires the route's tools into the generated LangGraph entry.

Input and output rulesCopy link to section: Input and output rules

  1. 1

    Use a typed, object-shaped input

    B4.run reads input types written inline, declared as local aliases or interfaces, or imported from another module. Keep the root input an object so B4.run can build the tool schema the model sees.

    Imports resolve through your app's tsconfig.json (the nearest one above the app root, with its extends chain, paths, and baseUrl). So an input type behind a path alias such as @acme/contracts gets the same schema as a relative import. If B4.run can't resolve a declared input type (the alias is missing from tsconfig.json, or the module has no such export), b4 typegen fails and names the tool file and the type. A tool that takes no input can omit the parameter or declare it as {}.

    ts
    export default async (input: { readonly tenant: string; readonly limit?: number }) => { ... }
  2. 2

    Mark fields readonly

    Type generation keeps readonly. Use it on every field, since tool inputs are plain data.

  3. 3

    Keep inputs and outputs JSON-serializable

    B4.run serializes tool inputs and outputs, so classes, Dates, Maps and functions get lost. Use primitives, plain objects and arrays.

  4. 4

    Return serializable data

    Output types can be inferred, annotated inline, declared as local aliases or interfaces, or imported from another module. Stick to primitives, arrays and plain objects.

A tool may return media. Return an array of content parts — { type: "text", text } and { type: "image" | "audio" | "video" | "document", source }, where source is { type: "data", value, mimeType } (base64), { type: "url", value, mimeType? } or { type: "file", value, provider? } — or { result: parts, state } to update state alongside. The type is B4ContentPart from @b4run/sdk. A document part must be a PDF (mimeType: "application/pdf"); a document sent by URL must carry that mimeType or it is dropped as document_not_pdf. Over AG-UI the parts travel as TOOL_CALL_RESULT.content; the model sees the media only when its profile and provider admit media in tool messages — on OpenAI it never does, though the UI still shows every part — and the result's text is what tool-output offloading bounds. See AG-UI.

The generated declarationsCopy link to section: The generated declarations

b4 typegen writes two artifacts:

  • .b4/b4.generated.d.ts: the type module behind import type { RouteTools } from "b4:routes". It maps each route pathname to that route's tool signatures, inferred from source.
  • .b4/routes/<routeSlug>/tools.json: one tool-schema manifest per route. b4 build reads these when it writes the LangGraph entries.

To see what B4.run generates for your app, run b4 typegen and open .b4/b4.generated.d.ts.

To regenerate them manually, run:

text
b4 typegen

Common patternsCopy link to section: Common patterns

  • External API wrappers: one tool per endpoint. The input mirrors the endpoint's parameters, and the output is the parsed response.
  • Database reads: the input holds the filters, and the output is the records. Keep queries cheap.
  • LLM calls: the input is the prompt variables, and the output is the parsed model response. Leave orchestration to the route entry.
  • Pure transformations: map one shape to another with no side effects. These are easy to test.
  • Wrapping an existing LangChain tool: create the community tool at module scope, then expose it through a plain function with a typed input. B4.run tools are plain functions (rather than tool() wrappers), which is how B4.run infers the types from the signature:
src/app/(public)/research/tools/search.ts
import { TavilySearch } from "@langchain/tavily"
 
const tavily = new TavilySearch({ maxResults: 5 })
 
/** Search the web for current information. */
export default async (input: { readonly query: string }) => {
  const results = await tavily.invoke({ query: input.query })
  return { results: String(results) }
}

The wrapper turns the community tool's loosely typed output into a plain object with a concrete return type. If the output is already serializable, you can skip it.