Write an environment
Connect your own runtime or workspace so Brain can run tools there.
An environment lets Brain run code in a service you control, such as a sandbox or a browser. Most of the time an existing environment is enough. Write your own when you need a different runtime or resource lifecycle.
This guide assumes you can run an HTTP service and control its deployment. If your request handler only needs to return a tool result, use the existing HTTP tool extension instead.
Run the JavaScript example
The lazy workspace example is a small HTTP service that echoes tool input and keeps a shared in-memory workspace. With Node.js 22 or newer:
git clone https://github.com/aexhq/brain.git
cd brain
npm ci
npm run build
REFERENCE_ENV_TOKEN=workspace-local node examples/lazy-environment.mjsIt listens on 127.0.0.1:8090. For this local example, run Brain on the same host from a
source build:
BRAIN_DATA_DIR="$PWD/brain-data" \
BRAIN_API_TOKEN=quickstart \
BRAIN_ENV_WORKER="$PWD/target/debug/brain-env-worker" \
./target/debug/brain --listen 127.0.0.1:8080These are Bash commands; on Windows, use PowerShell environment assignments and the .exe
binaries. A Brain running in Docker can't reach this service through its own 127.0.0.1.
Connect a tool
In the quickstart app, import environment and replace the order tool with:
import { environment } from "@aexhq/brain";
const workspace = environment({
url: () => "http://127.0.0.1:8090",
credential: () => "workspace-local",
});
const echo = tool({
name: "echo",
description: "Echo a message in the workspace.",
input: z.object({ message: z.string() }),
implementation: { type: "reference_echo" },
});
// In sessions.create:
// tools: [echo({ env: workspace({ name: "workspace" }) })],Send "Call echo with the message hello." and run the app. The transcript shows the echoed input
and a workspace entry count. JavaScript needs no build step; for a TypeScript service, use your
normal compiler.
Edit the example's execute handler to run your own fixed operation. It calls the supplied
finish callback before returning a receipt, which tells Brain the tool has completed.
The SDK's environmentProtocol validates commands and receipts against Brain's generated contract,
and serveEnvironment authenticates and serves them over HTTP:
import { environmentProtocol, serveEnvironment } from "@aexhq/brain/runtime";
const handle = environmentProtocol(async operation => provider.run(operation));
const server = await serveEnvironment(handle, { token: process.env.ENVIRONMENT_TOKEN, port: 8090 });Here provider.run implements the operations for your runtime. serveEnvironment requires a token
or an authenticate function, limits request body size and validates commands before your provider
runs. A few helpers cover the rest:
finishExecution(operation, receipt)commits completion for a synchronous Tool.callExecutioncalls other granted services.reportEnvironmentsends binding observations.
Each callback is sent once, and a lost completion acknowledgement returns an unknown outcome. Returning a receipt alone doesn't finish a Tool that's still running in the background.
Add typed methods and observations
environmentHandler validates provider options and named method inputs, just like a Tool. Your
service authenticates Brain's request, then passes the decoded command to handler.run(). This
excerpt replaces lifecycle handling inside your service and assumes provider is your integration
with attach, health, release and resource-loss callbacks:
import { environmentHandler } from "@aexhq/brain";
import { z } from "zod";
const handler = environmentHandler({
options: z.object({ region: z.string() }),
async setup(ctx) {
await provider.attach(ctx.sessionId, ctx.environment.name, ctx.options.region);
const reporter = ctx.reporter;
provider.onResourceLost(ctx.environment.name, async resource => {
await reporter.emitResult({
scope: "resource", resource, code: "lost", message: "The resource is no longer available.",
});
});
},
methods: {
inspect: {
description: "Read current provider health.",
input: z.object({ resource: z.string() }),
run: (input, ctx) => provider.inspect(ctx.environment.name, input.resource),
},
},
teardown: ctx => provider.release(ctx.environment.name),
});Hand the provider's watchers only ctx.reporter, and keep operation services inside the handler.
The reporter can outlive setup and carries no control authority. An operation can call another
authorized environment through ctx.environments, with grants separate from those of any Tool or
Tool it hosts. Export handler.methods on the environment(...) factory so the application
can grant the methods it chooses.
Use effect: "replace" for a method that replaces the whole execution environment, and ordinary
methods to manage resources inside it. Brain handles references, grants, journaling and activation;
your Env implements the provider behavior. See Control environments.
Python and other languages
An environment service speaks HTTP and JSON, so you can write it in any language with an HTTP
server. Brain ships tested JavaScript services and a
Python project runner.
The runner itself is JavaScript; it prepares and runs Python tools with uv and their lockfiles.
If your Python code only needs computation and Brain callbacks, the smaller packaged Python tool is simpler. For services in other languages, implement the Environment protocol; Brain doesn't provide a separate environment SDK for each language.
Deploy and use it
Deploy the service somewhere your Brain server can reach. Set its URL and credential in the
environment(...) factory, then create a new session with that environment. Prepare code and
dependencies before running the tool, and decide when workspaces are kept and released.
The local example doesn't isolate untrusted code. A deployed service must authenticate requests, validate operations and limit the code's access to files, secrets and the network. The protocol reference covers setup, execution, cancellation and cleanup.
For supported hosted integrations, see the Aex docs.