Agent.bx
The advanced option - a class that extends bx-ai's own AiAgent, for definitions that need code.
On this page
Agent.bx
The default way to define an agent is a plain Agent.yaml. Reach for Agent.bx when the definition itself needs code: hooks, dynamic instructions, tools registered in code, an overridden method, or detectEnvironment(). A folder holds one or the other, never both. The sections on the model slug, environment overrides, merge semantics and the always-on agent route below apply to both.
Agent.bx is the advanced way to define an agent. A BxAgents project needs an Agent.yaml or an Agent.bx. It extends bx-ai's own AiAgent, so it is the agent - the build instantiates it rather than rebuilding one from a config struct, so what you write is what runs. Inherit and add whatever you need: private helpers, overridden methods, tools registered in code. Because it's a real class rather than a struct-returning descriptor, an IDE can introspect it like any other BoxLang class - jump to definition, autocomplete on inherited methods, the works.
class extends="bxModules.bxai.models.runnables.AiAgent" {
function init() {
super.init(
name : "my-agent",
description : "A helpful assistant",
instructions: "You are a helpful assistant.",
model : aiModel( provider: "openai", params: { model: "gpt-5" } )
)
return this
}
}
bxAgents new --class scaffolds exactly this shape (without --class, new scaffolds an Agent.yaml). instructions.md is optional - set instructions directly in super.init(), or drop the file beside Agent.bx and let the build wire it in for you (see the table below).
What the build layers on top of the class
The rule: an explicitly declared convention wins; otherwise the class is in charge.
So an agent that sets everything in its own init() gets nothing imposed on it, while one that sets the bare minimum still picks up the conventions it didn't speak for. Everything below is optional - declare a configure() returning any of these keys to override what the class itself set, or the matching instructions.md/tools//subagents/ convention folder:
| You declare | The build emits | If you don't declare it |
|---|---|---|
instructions.md | withInstructions( fileRead( ... ) ) | the class's own instructions stand |
model in configure() | setModel( aiModel( ... ) ) | the class's own model stands |
name / description in configure() | setName() / setDescription() | the class's own stand |
memory in configure() | setMemory( ... ) | the class's own stands |
the tools/ folder | withTools( ... ) with that folder's tools | always - withTools() appends in bx-ai rather than replacing, so tools your class registered itself are kept and the agent's own tools/ are added |
tools / toolOptions in configure() | withTools( ... ) with each item@module / *@module listed, built with the module's options | no module tools; see Tools and Tool options |
subAgents on the class, or subagents/ on disk | addSubAgent( ... ) per child | appended the same way |
checkpointer in configure() | withCheckpointer( ... ) | injected anyway if the class set none - see below |
The checkpointer is the one thing the build fills in unasked. An agent reachable from a gateway with no checkpointer has silently broken human-in-the-loop, so a class that set none still gets the cache default. A class that sets its own is left alone.
Deliberately not implemented by comparing your instance against bx-ai's DEFAULT_AGENT_* values. "Did the author mean this, or is it just the default?" is unanswerable, and an author who genuinely wanted the default name would find it silently replaced. Presence of an external declaration is a fact; intent behind a default value is not.
The class is copied into the generated app at agent/classes/ and instantiated there by its own absolute file path (never a relative lookup that would depend on a registered mapping), exactly as tools/, skills/ and mcp/ are copied - so a packaged .bxa carries it, and chat/invoke/serve all instantiate it the same way whether or not a real ColdBox container is booted.
configure() (optional) - overriding what the class set
A configure() method is entirely optional. Declare it only to override specific fields from outside the class - useful for keeping deployment-specific values (a different model per environment, say) out of the class body itself:
class extends="bxModules.bxai.models.runnables.AiAgent" {
function init() {
super.init(
name : "with-mcp-servers-agent",
model : aiModel( provider: "openai", params: { model: "gpt-5" } )
)
return this
}
function configure() {
return {
mcpServers : [
"https://example.com/mcp",
{ url : "https://other.com/mcp", name : "other" }
]
};
}
}
| Field | Type | Notes |
|---|---|---|
name | string | Also becomes this agent's config/WireBox.bx binding key at build time (getInstance( name )) - see schedules/ - so it must be unique across the whole project (root + every subagent); build fails if two agents share a name. |
model | string | A provider/model slug or a bare provider name. A profile (see Models and profiles) can stand in for it. See below. |
description | string | Optional. |
subAgents | array of strings | Names of sibling folders under the root project's subagents/. See subagents/. |
mcpServers | array | Remote MCP servers - each entry a URL string or { url, name }. See mcp/. |
security | struct | Forwarded verbatim into the generated app's bxai module settings; bx-ai's own SecurityDirector turns it into guardrail middleware. Passthrough only - BxAgents has no own guardrails convention. |
memory | string or struct | The agent's conversation memory. A bare string is shorthand for the type ("cache"); a struct is { type, ...config } and is passed through to aiMemory() verbatim - e.g. { type: "cache", maxMessages: 50 }, or with summaryProvider/summaryModel/summaryThreshold to make the web UI's /compact functional. Applies per node, so a subagent can declare its own. |
checkpointer | struct | { type: "cache"\|"file"\|"jdbc", ...config }. Defaults to { type: "cache" } if omitted. Always applied - without one, human-in-the-loop approval flows through any gateway other than cli fail outright. |
gatewaySession | struct | { policy, maxQueueDepth, commands }, all optional (default "queue" / 50 / a safe set of slash commands). Only meaningful if the project has at least one push-style gateway entry - controls the generated GatewaySession's policy for a second inbound message arriving on a thread that already has a turn in flight. policy must be one of reject/queue/steer/interrupt. |
agentApi | struct | { enabled, tokenEnvVar, tokens, profiles }, all optional. Controls the always-on agent route at /__bxagents. See The always-on agent route below. |
| any other key | any | Merged and available in the resolved config struct, but not interpreted by BxAgents itself. |
The model slug
model is BxAgents' own convention - bx-ai itself takes provider and model as two separate arguments to aiModel(). BxAgents splits the slug on the first / only, so a provider that itself contains a slash (like OpenRouter's openrouter/anthropic/claude-x) still parses correctly:
model value | provider | model |
|---|---|---|
openai/gpt-5 | openai | gpt-5 |
openrouter/anthropic/claude-x | openrouter | anthropic/claude-x |
mock/mock-model | mock | mock-model |
If model has no / at all, it must be a known core provider name (the provider's default model) - validation rejects anything else. The recognized core providers are: bedrock, cartesia, claude, cloudflare, cohere, deepseek, docker, elevenlabs, gemini, grok, groq, huggingface, minimax, mistral, mock, ollama, openai, openai-compatible, openrouter, perplexity, voyage (kept in sync with bx-ai's own CORE_PROVIDERS). mock is a real provider, useful for tests and CI - it never makes a network call.
This slug-splitting convention is what a configure()-declared model string goes through - super.init()'s own model argument instead takes a real AiModel instance directly (aiModel( provider: "...", params: { model: "..." } )), since the class already speaks bx-ai's own API.
Environment overrides
Agent.bx may declare a method named after an environment (e.g. production(), development(), or any custom name) returning a struct of overrides - this works whether or not the class also declares a configure():
class extends="bxModules.bxai.models.runnables.AiAgent" {
function init() {
super.init(
name : "override-agent",
description : "An agent with an environment override",
model : aiModel( provider: "openai", params: { model: "gpt-5" } )
)
return this
}
function production() {
return {
model : "openai/gpt-5-mini"
};
}
}
The active environment is resolved with this precedence (highest wins):
--environmentCLI flag (bxAgents build --environment=production)Agent.bx's owndetectEnvironment()method, if it declares oneBX_AGENTS_ENVenvironment variable"development"(default)
detectEnvironment()
For anything the first and third tiers can't express, Agent.bx may declare a
detectEnvironment() method and decide for itself. Whatever it returns is taken
as the environment name verbatim:
class extends="bxModules.bxai.models.runnables.AiAgent" {
function init() {
super.init( name : "my-agent", model : aiModel( provider : "openai", params : { model : "gpt-5" } ) )
return this
}
function detectEnvironment() {
return fileExists( expandPath( "/.production-marker" ) ) ? "production" : "development";
}
}
This mirrors ColdBox's own tier-1 convention (a config declaring
detectEnvironment() has its return value taken verbatim), with two deliberate
differences, both because this runs at build time:
- An explicit
--environmentflag outranksdetectEnvironment()here, where ColdBox puts its own detect method first. A flag someone typed is the most specific statement of intent available. - ColdBox's other detection tier - an
environmentsstruct of regexes matched againstCGI.HTTP_HOST- is not supported. It cannot work here: the build runs in a CLI process with no HTTP host, so it would only ever describe the machine doing the building (a CI runner would resolvedevelopmentfor a production build).
Returning a blank string, or anything that isn't a string, means "no opinion" and
falls through to the next tier - so a project computing its environment from
something absent on this machine degrades to the default rather than failing the
build. A detectEnvironment() that throws is a different matter: that's a real
bug in the project's own code, and build fails and says so.
This is a build-time decision only, distinct from ColdBox's own runtime environment detection (the generated app reads getSetting("environment") on its own, per ColdBox's environments convention) - this precedence only decides which environment() override method on Agent.bx, and which boxlang-{env}.json/miniserver-{env}.json files, the build pipeline applies.
If no method matching the active environment exists, no override is applied.
Merge semantics
The full resolution order (lowest to highest precedence) is:
configure()(optional)- the matching environment-override method, if any
boxlang.jsonboxlang-{environment}.jsonminiserver.jsonminiserver-{environment}.json
Struct keys are merged recursively - a nested struct in a higher-precedence source only overrides the keys it actually sets, leaving sibling keys from a lower-precedence source intact. Arrays and all scalar values are replaced whole, never appended or concatenated.
boxlang.json/boxlang-{env}.json/miniserver.json/miniserver-{env}.json are all optional project-root JSON files, useful for config that's easier to express as data than as BoxLang code (e.g. model defaults):
// boxlang.json
{
"modelDefaults": { "temperature": 0.7, "maxTokens": 1000 }
}
// boxlang-production.json
{
"modelDefaults": { "temperature": 0.2 }
}
Building with --environment=production here yields modelDefaults: { temperature: 0.2, maxTokens: 1000 } - the recursive merge kept maxTokens from the base file since boxlang-production.json never mentioned it.
Secrets (API keys, tokens) are never read or merged by BxAgents at build time - they stay external (an OS environment variable, .env, a platform secret manager) and are resolved live by bx-ai itself at runtime. See Deployment & Secrets.
The always-on agent route
Every built project exposes its root agent over HTTP at a fixed reserved path,
whether or not it declares any gateways/ entries:
POST /__bxagents/invoke
POST /__bxagents/stream # SSE
POST /__bxagents/batch
GET /__bxagents/info
These are the four sub-routes ColdBox's own toAi() terminator registers. The
route exists so this module's own tooling always has an address to talk to -
it's the service entrance, not your project's public API. A project's own
public surface belongs in gateways/, at whatever paths you choose.
Run control
Beside those four, a generated handler (handlers/AgentApi.bx) serves what a chat client needs to
control a run. It is what chat --server and chat --connect use:
POST /__bxagents/events # SSE: like /stream, plus tool and suspended frames. Body may carry
# attachments: [ { name, mime, data (base64) } ], at most 4 pictures of 5 MB
POST /__bxagents/cancel # { threadId, reason? }
POST /__bxagents/steer # { threadId, input }
GET /__bxagents/pending # ?threadId=
POST /__bxagents/resume # { threadId, decision } -> SSE, like /events
GET /__bxagents/tools
GET /__bxagents/profiles # the profiles a client may ask for
GET /__bxagents/agent # name, model, what it can do
GET /__bxagents/sessions # the caller's recent conversations
POST /__bxagents/sessions/rename # { conversationId, title } name one
POST /__bxagents/sessions/fork # { conversationId, title? } copy one, messages and all
GET /__bxagents/history # ?conversationId= what was said in one
POST /__bxagents/clear # { conversationId } forget its messages
POST /__bxagents/compact # { conversationId } summarize the older messages
POST /__bxagents/ping # { profile? } test a model with one tiny question: { ok, model, ms, reply } or { ok : false, model, reason }
GET /__bxagents/commands # the slash commands this caller may run, [ { name, hint } ]
GET /__bxagents/context # ?conversationId= estimated size of the context: parts, largest tools, total, share of the window
POST /__bxagents/command # { line : "/info", conversationId? } run one, answers { blocks, effects, conversationId }
GET /__bxagents/notes # the caller's notes and the agent's, with how full they are
POST /__bxagents/notes # { scope : "user" | "agent", text }
POST /__bxagents/notes/update # { scope, id, text }
POST /__bxagents/notes/remove # { scope, id }
/events sends the frames of /stream (thread first, chunk for each piece of the answer, done last)
and adds:
| Frame | Data |
|---|---|
tool | { id, name, arguments, status, ms }, once with status: "running" and once with "ok" and the time it took |
suspended | { threadId, pendingActions : [ { toolName, toolArgs } ] } when the run waits for a human decision |
error | { message } |
cancel stops the run at its next checkpoint (before the next model call or tool call, not in the middle of
a token). steer splices a message into a run in flight. resume takes approve, approve_session or
reject and streams the rest of the turn. A run belongs to whoever started it: pending answers "nothing
pending" and resume answers 403 to anyone else.
Send a conversationId with /events to keep the turn in a conversation: the server lists it under the caller (the name of the token used, or api on an open route) and the agent's memory keeps its messages under the same id. sessions, history, clear and compact work on the caller's own conversations only; someone else's id is a 404, the same as one that does not exist. They answer 404 when the server has no shared chat database, and compact reports available: false when the agent's memory has no summary model.
The notes routes answer 404 for an agent that has no notes block. Each caller reads and changes their own user notes (the owner is the name of the token they used, or api on an open route) and the agent's notes. Any caller can add to either scope or edit a note; removing a note of the agent needs an admin token (a 403 otherwise), because it changes what every user's agent knows.
These routes sit behind the same x-bxagents-token gate as the other four (a missing or wrong token is a
401), and agentApi.enabled: false removes all of them.
Because the path is reserved, build rejects any gateways/ entry whose
path is /__bxagents or sits underneath it. Two routes on one mount is a
genuine conflict: one would silently shadow the other, and yours would be the
one that never answers.
agentApi
configure() may return an agentApi struct to control it:
| Key | Type | Notes |
|---|---|---|
enabled | boolean | Default true. Set false to omit the route entirely - for a build going somewhere it has no business existing. |
tokenEnvVar | string | The name of an environment variable holding a shared secret. When set, every sub-route requires a matching x-bxagents-token request header. |
tokens | boolean | Default false. true also accepts the access tokens made with bxAgents token create. |
profiles | list | Default none. The profiles a client may ask for with options.profile on /events, and what GET /__bxagents/profiles lists (never a key). Any other profile is a 400, so a caller cannot pick a model you did not offer. chat --connect uses this list for /profile. Each name must be declared in Agent.yaml, or default. |
function configure() {
return {
name : "my-agent",
model : "openai/gpt-5",
agentApi : {
tokenEnvVar : "BXAGENTS_API_TOKEN"
}
};
}
Because this comes from configure(), it scopes per environment with an
environment override like any other key - so a
project can leave the route open locally and require a token in production:
function production() {
return {
agentApi : { tokenEnvVar : "BXAGENTS_API_TOKEN" }
};
}
The gate fails closed: if the named variable is unset or blank at runtime,
nothing matches - it never degenerates into comparing "" to "" and letting
every caller through. A request without a valid token gets a 401 with a small
JSON body, and never reaches the agent.
tokenEnvVar names a variable, never the secret itself. build rejects
a value that isn't a valid environment-variable name, precisely so a literal
token can't be committed here by mistake. Only the variable's name is ever
written into generated source.
Access tokens for a deployed agent
One shared secret (tokenEnvVar) is enough for a single caller. To let several people or machines in, each with a name you can revoke, turn on issued tokens:
# Agent.yaml
agentApi:
tokens: true
Make them where the agent's database is: on the server, or in the project before you pack it.
bxAgents token create --name=laptop --expires=90d
bxAgents token create --name=ci --scope=admin
bxAgents token list
bxAgents token revoke --name=laptop
- A token is shown once, when it is made. Only its SHA-256 is stored, so a stolen database file cannot be used to call the agent.
- The caller sends it in the
x-bxagents-tokenheader, orbxAgents chat --connect=<url> --token=<token>(orBXAGENTS_TOKEN) does. - The name is who is calling. A run belongs to the token that started it, and one token cannot answer another's pending approval. The scope is
chatoradmin. - Revoking takes effect on the next request, without a restart. A token can also expire (
--expires=90d,12h,2wor a date). - With
tokens: trueand notokenEnvVar, a server with no tokens yet refuses everyone. With both, the shared secret works as anadmintoken, so a fresh deployment can be reached before the first token exists. - The tokens live in the shared chat database (
data/chat.db). On a deployed server runtokenwithBXAGENTS_DBpointing at the file the app uses.
Put HTTPS in front of any server a token travels to: a token on plain HTTP can be read on the wire.
The route is bound to whatever host the miniserver
is listening on - 127.0.0.1 by default, so nothing is reachable off the
machine unless you deliberately expose it. tokenEnvVar is what matters once
you do, and for any built .bxa artifact you deploy.