Tools
One tool module runs on every agent harness. beeuni.yaml lists the Beeuni tools and the project's own tools, and each agent's tools list decides who may call them.
A tool is a function the model calls during a turn. A session has three kinds, on both harnesses, default runtime and experimental runtime:
| Kind | Names | Implemented by |
|---|---|---|
| Harness tools | bash, read, write, edit, glob, grep, task, question, … |
The harness. |
| Beeuni tools | web_search, image_search, scrape_webpage, memory, show |
Beeuni. beeuni.yaml lists each one as <name>: beeuni:<name>. A project with no tools: key gets all five. |
| Project tools | Any name you declare | Your project: one module, declared under tools: in beeuni.yaml. |
Beeuni tools and project tools are harness-neutral. The sandbox daemon loads one module per tool and runs it for either harness, with the same name, arguments, and output. The experimental runtime calls the module in the daemon’s process. The default runtime calls it through a plugin the daemon writes, which forwards each call to the daemon. A module never imports a harness package.
Beeuni tools in beeuni.yaml
A new project lists the five Beeuni tools under tools::
tools:
web_search: beeuni:web_search # maintained by Beeuni, updated automatically
image_search: beeuni:image_search
scrape_webpage: beeuni:scrape_webpage
memory: beeuni:memory
show: beeuni:show
# lookup_order: tools/lookup_order.ts # your own tool: a module in your repo
beeuni:<name> means: the tool Beeuni maintains. A fix to it reaches every
project that lists it, with no change in the project.
Which Beeuni tools a session gets
beeuni.yaml |
The session gets |
|---|---|
No tools: key, in the root file or in any imported file |
All five Beeuni tools. A project created before this list existed keeps them all. |
A tools: key, even an empty one (tools: {}, or tools: with every line deleted) |
Only the Beeuni tools it lists, as beeuni:<name> or as a module that replaces the tool. |
The project’s own tools load in both cases.
See what a session gets
beeuni tools ls reads beeuni.yaml and its imports and prints each tool a
session gets, then the Beeuni tools the project removed:
$ beeuni tools ls
NAME KIND SOURCE
web_search beeuni (overridden) tools/web_search.ts
scrape_webpage beeuni beeuni:scrape_webpage
memory beeuni beeuni:memory
show beeuni beeuni:show
lookup_order project tools/lookup_order.ts
Removed Beeuni tools: image_search
Add `<name>: beeuni:<name>` under `tools:` to get one back.
| Kind | Meaning |
|---|---|
beeuni |
A Beeuni tool, listed as beeuni:<name> (or every one, with no tools: key). |
beeuni (overridden) |
A Beeuni tool name with a module path: the project’s module runs instead. |
project |
A tool of the project’s own. |
beeuni tools ls --json prints { manifest, tools_key, tools: [{ name, kind, source }], removed }.
Remove a Beeuni tool
Delete its line under tools:. Every agent of the project loses the tool,
on both harnesses, in every session that starts after the change request
merges. To keep a tool from one agent only, use
agents.<name>.tools instead.
A session of a project that removed a tool:
- default runtime does not offer the tool to the model.
- experimental runtime does not offer the tool. A call to it fails with
Tool show not found(forshow). - The daemon answers
POST /beeuni/tools/<name>with404andno tool named <name> is loaded. - A skill that names the tool still loads. For example,
beeuni-memorynamesmemory. The model reads the skill, finds no such tool, and uses other tools: withoutmemoryit reads and edits the files undermemory/with the file tools.
Change a Beeuni tool: eject
beeuni tools eject <name> gives the project its own copy of a Beeuni tool:
$ beeuni tools eject web_search
✓ Ejected web_search
wrote tools/web_search.ts (the Beeuni source, now the project's to change)
set beeuni.yaml: tools.web_search: tools/web_search.ts
Next:
1. Edit tools/web_search.ts. Keep the default export: { description, parameters, execute(args, context) }.
2. beeuni validate
3. git add tools/web_search.ts beeuni.yaml && git commit -m "Own the web_search tool"
4. Open a change request: git push origin HEAD && beeuni cr open --title "Own the web_search tool"
Sessions load the copy after the change request merges.
- It writes the Beeuni source, the same bytes the sandbox runs, to
tools/<name>.ts. The file imports onlynode:*modules and readsBEEUNI_API_URL,BEEUNI_TOKEN, and provider keys fromcontext.env, so it runs as it is. - It sets
<name>: tools/<name>.tsinbeeuni.yaml, in the file that declares the entry (the root file or an imported file). Every other line and comment stays. The changed line ends with# ejected from beeuni:<name>. - With no
tools:key, it creates one and lists the other four Beeuni tools asbeeuni:<name>, so the eject removes nothing. - It refuses when
tools/<name>.tsexists, or when the tool already points at another module.--forceoverwrites. - It refuses a name that is not a Beeuni tool.
The copy is yours. Beeuni no longer updates it. To return to the maintained
tool, set the line back to <name>: beeuni:<name> and delete the file.
Override without eject
A Beeuni tool name with a module path replaces the Beeuni tool, on every harness:
tools:
web_search: integrations/search/web_search.ts
The web UI views
The web UI has a view for each Beeuni tool, picked by the tool name. The
show and memory views read the call’s arguments (type, path, url,
content, items; command, path). The web_search, image_search, and
scrape_webpage views read the output JSON. A changed copy that keeps the
name must keep those arguments and that output shape, or the UI shows the raw
output.
Validation
beeuni validate checks the list:
<name>inbeeuni:<name>must be a Beeuni tool, and the key must equal it.web_search: beeuni:image_searchis an error:must be `beeuni:web_search` (the Beeuni tool) or a repo-relative path to a .ts or .js module (e.g. `tools/web_search.ts`) that replaces it.- A
tools:key that lists no Beeuni tool is a warning:Sessions of this project get no Beeuni tool (web_search, image_search, scrape_webpage, memory, show). Add `<name>: beeuni:<name>` to keep one.It catches a project that adds its first own tool and drops the five by accident. - An agent whose
toolsnames a Beeuni tool the project does not load is a warning:"show" is a Beeuni tool this project does not load: add `show: beeuni:show` under the top-level `tools`, or remove it here.
The manifest reference has every rule.
Write a project tool
// tools/lookup_order.ts
export default {
description: 'Look up an order by id. Returns its status and carrier.',
parameters: {
type: 'object',
properties: {
id: { type: 'string', description: 'The order id, e.g. "A-1042"' },
include_items: { type: 'boolean', description: 'Also list the items' },
},
required: ['id'],
},
async execute(args, context) {
const response = await fetch(`https://orders.example.com/v1/orders/${args.id}`, {
headers: { authorization: `Bearer ${context.env.ORDERS_API_KEY}` },
signal: context.signal,
})
if (!response.ok) throw new Error(`orders API answered ${response.status}`)
return await response.json()
},
}
# beeuni.yaml
tools:
web_search: beeuni:web_search
# … the other Beeuni tools you keep
lookup_order: tools/lookup_order.ts
The default export is a plain object:
| Field | Rule |
|---|---|
description |
Non-empty string. The model reads it to decide when to call the tool. |
parameters |
JSON Schema of the arguments, type: 'object'. The model receives it as it is, so list required arguments in required. Beeuni does not validate the arguments against it. |
execute(args, context) |
Returns a string, or any JSON value (sent as indented JSON). May be async. A thrown error is the tool’s error, and the model reads its message. |
context carries:
| Field | What it is |
|---|---|
sessionId |
The Beeuni session the call belongs to. |
agent |
The agent that made the call. |
directory |
The project checkout, /workspace. Resolve a relative path against it. |
env |
The box environment plus the secrets this agent receives, as they are now. Read a secret as context.env.NAME: a secret changed after the box booted is in context.env, not in process.env. |
signal |
An AbortSignal that aborts when the user stops the turn. Pass it to fetch. |
- Output size. An output over 50 KB or 2,000 lines is saved whole to a file in the sandbox’s temp directory. The model receives the head, the size, and the file path. Return what the model needs, not a raw dump.
- Errors. A thrown error ends the call, and the model reads the error message. Throw a message that says what to change.
- Imports. A module may import
node:*modules, Bun APIs, and relative files. A package resolves from anode_modulesnext to the module or above it. The sandbox installs no package for a tool, and a config release holds only the files Git tracks. Usefetchfor HTTP APIs. TypeScript runs without a build step. - Failures. A module that does not load (a missing file, no default
export, a
parametersthat is not an object schema, an error at import, a package that does not resolve) is left out, and the other tools still load. The daemon logs[tools] project tools that did not loadwith the reason per tool.
parameters with zod
zod 4 writes the same JSON Schema:
import { z } from 'zod'
export default {
description: 'Look up an order by id.',
parameters: z.toJSONSchema(z.object({ id: z.string().describe('The order id') })),
async execute(args) {
return `order ${args.id}: shipped`
},
}
This module loads only where zod resolves from a node_modules in the
checkout the session runs. Otherwise it fails with Cannot find package 'zod'.
To write the schema with zod and ship plain JSON Schema, print it once and
paste the output into parameters:
bun -e "import { z } from 'zod'; console.log(JSON.stringify(z.toJSONSchema(z.object({ id: z.string() })), null, 2))"
Example: call an HTTP API with a project secret
Store the key as a project secret. The command reads the value from stdin:
beeuni secrets set WEATHER_API_KEY=-
Grant the secret to the agents that call the tool. The starter’s beeuni
agent has secrets: all. A scoped agent lists it: secrets: [WEATHER_API_KEY].
Write the module:
// tools/weather_now.ts
export default {
description: 'Current weather for a city: temperature in °C and conditions.',
parameters: {
type: 'object',
properties: { city: { type: 'string', description: 'City name, e.g. "Berlin"' } },
required: ['city'],
},
async execute(args, context) {
const key = context.env.WEATHER_API_KEY
if (!key) throw new Error('WEATHER_API_KEY is not set: run `beeuni secrets set WEATHER_API_KEY=-` and grant it to this agent.')
const url = `https://api.weather.example.com/v1/current?city=${encodeURIComponent(args.city)}`
const response = await fetch(url, { headers: { authorization: `Bearer ${key}` }, signal: context.signal })
if (!response.ok) throw new Error(`weather API answered ${response.status}: ${await response.text()}`)
const data = await response.json()
return { city: args.city, temperature_c: data.temp_c, conditions: data.conditions }
},
}
Declare it:
tools:
# … the Beeuni tools you keep
weather_now: tools/weather_now.ts
Example: override web_search
beeuni tools eject web_search writes tools/web_search.ts and sets
web_search: tools/web_search.ts. Then change the copy. For example, to keep
results from one site only, add the site to each query in execute, before
the request:
// tools/web_search.ts, inside execute()
const queries = String(args.query ?? '').split('|||').map((query) => query.trim()).filter(Boolean)
.map((query) => `site:docs.example.com ${query}`)
Keep the name, the arguments, and the output JSON, so skills that name
web_search and the web UI view still work.
Test a tool
beeuni validate checks the name, the path, a missing file, and the
beeuni: values. beeuni tools ls shows what a session gets.
Then commit, push, and open a change request. After it merges, start a session and ask for one call with real arguments:
beeuni sessions new --wait --prompt "Call the weather_now tool once with city Berlin and print its output."
A tool that does not load is not offered to the model. A tool that throws
answers with its error message. beeuni sessions log <id> prints the
session’s messages, with each tool call and its status.
Declare tools
tools: maps each tool name to its module, or a Beeuni tool name to
beeuni:<name>. The folder of a module is yours to choose.
tools:
web_search: beeuni:web_search
memory: beeuni:memory
lookup_order: tools/lookup_order.ts
crm_note: integrations/crm/note.ts
- A name is snake_case, at most 64 characters:
^[a-z][a-z0-9_]{0,63}$. - A harness tool name is an error. A Beeuni tool name takes
beeuni:<name>or a module path; the module replaces the Beeuni tool, on every harness. - A path is repo-relative and ends in
.ts,.js,.mts,.mjs,.cts, or.cjs. - A file listed under
imports:may declare tools. A name declared in two files is an error. Atools:key in an imported file counts as atools:key for the selection rule.
The manifest reference has the full rules.
Who may use which tool
agents.<name>.tools decides which tools an agent’s model sees. It names
harness, Beeuni, and project tools alike.
agents:
beeuni:
tools: all # every tool (the default)
researcher:
tools: [read, grep, web_search, lookup_order] # only these
support:
tools:
exclude: [bash, write, edit] # every tool but these
greeter:
tools: none # no tool
| Value | The agent gets |
|---|---|
omitted, or all |
Every tool. |
none |
No tool. |
| a list | Only the tools listed. A list with * is all. |
{ exclude: [...] } |
Every tool except the ones listed. |
{ bash: false, … } |
The earlier map form: false removes a tool. |
toolsdecides visibility. The agent’spermissionrules still decide whether a call is allowed, asked, or denied. A tool not in the list is hidden from the model and denied if called.- An agent list cannot add a Beeuni tool the project does not load. List it
under the top-level
tools:first. - A subagent started with the
tasktool: on the experimental runtime it gets only the tools both its own list and the session agent’s list allow. on the default runtime it gets the tools of its own list: default runtime applies an agent’s rules to that agent only. To stop an agent from delegating, leavetaskout of its list. - Connector tools follow
agents.<name>.connectors, not this list. beeuni validatewarns about a name that is not a harness, Beeuni, or project tool, and about a Beeuni tool the project does not load. A typo in anexcludelist leaves the real tool on.
on the default runtime, bash also covers the terminal plugin’s pty_* tools, and
write, edit, and apply_patch share one permission: listing one shows
all three, and excluding one hides all three. The experimental runtime has separate write and
edit tools.
When a change takes effect
A session loads its tools when its runtime starts. A change applies after the change request merges: new sessions load it, and with config releases a running session loads it at the next convergence. on the experimental runtime, a change to a project tool or to the list of Beeuni tools restarts the runtime in place while no turn runs; the conversation continues.
Tools in harnesses/opencode/tools/
The default runtime still loads custom tools from its config directory. The experimental runtime does not.
Projects created before hosted tools carry copies of the Beeuni tools there
(web_search.ts, memory.ts, …).
- on the default runtime, a tool in that folder wins over the hosted tool of the same
name, so a project keeps its copy until it deletes it. Delete the copies of
the Beeuni tools: the daemon hosts the ones
beeuni.yamllists, on both harnesses. - To make an default runtime tool run on both harnesses, move it out of the folder,
replace
tool({ description, args, execute })with a plain object whoseparametersis JSON Schema, read secrets fromcontext.env, and declare it undertools:.