briyah
v2.0.6
Published
SDK for AI agents, multi-agent rooms, interactive stories, and role-playing games with per-user data isolation and cost tracking
Maintainers
Readme
Briyah SDK
Briyah is an SDK for AI agents, multi-agent rooms, and interactive stories. It wraps the Briyah runtime as a single class with per-user data isolation and balance tracking, and supports Anthropic, OpenAI, Google, Grok, DeepSeek, Together, and Fal as LLM providers.
What's in the package:
- Agents. AI agents with a system prompt, persistent conversation history, attached documents, and per-call cost and token tracking. Histories can be summarized/compacted or cleared.
- Rooms. Multi-agent conversations with a shared goal. Agents exchange messages at different visibility levels (speak, whisper, think, shout), publish shared artifacts, run tools, commission each other for parallel research, and a moderator can route turns.
- Tools. Functions agents can call from a room, granted per agent. Two kinds:
- Native tools — real functions you register in your host code with
briyah.registerTool(see Example 8). They have full access to your imports, database, and filesystem, and receive an opaque per-invocation context you supply — use it to scope a call to trusted data the model cannot set. This is the recommended way for SDK integrations. - User-authored tools — named JS-string bodies stored per user and run in-process with a timeout (
helpers.fetchavailable). Off by default; enable withallowUserAuthoredToolsonly when you trust the authors. On multi-user hosts, leave off and expose native tools instead. An agent may chain several tool calls privately before responding.
- Native tools — real functions you register in your host code with
- Stories. Text-based interactive fiction with a narrator, AI-played characters, and a human player. Stories progress through chapters and can introduce new characters mid-game. Story events (state updates, character suggestions, chapter transitions, errors) are delivered through an emitter the host app subscribes to.
- Text processing. One-shot
processTextfor non-conversational use, with cost and token counts on the result. - File attachments. Attach documents to agents as context. Supported file types vary by LLM provider.
- Per-user data. Agents, rooms, stories, attached files, balances, and transactions are scoped to a userId the host controls. Per-user directories keep state isolated.
- Balances and transactions. Track per-user balances against usage cost. Record pending, succeeded, failed, or cancelled transactions when integrating with a payment provider, credit balances on webhook confirmation, and resume stories that paused on insufficient funds. Briyah does not process payments itself.
- Real-time event emitters. Story-state and balance updates can be forwarded to SSE or websockets.
State is persisted as JSON files under a configurable data path; no external database is required.
Installation
npm install briyahimport { Briyah } from 'briyah';Quick Start
import { Briyah } from 'briyah';
// 1. Create and initialize Briyah instance
const briyah = new Briyah({
dataPath: './my-briyah-data',
userServiceCacheTimeoutMinutes: 30,
startingBalance: 10.00
});
await briyah.init();
// 2. Get user-specific service (userId managed by your app)
const appService = briyah.getAppService('user-123');
// 3. Use full Briyah functionality
// createAgent() registers the agent in memory immediately — agent.id is usable right away.
// Call agent.save() when you want to persist it to disk.
const agent = appService.createAgent(
'Anthropic',
'AI Assistant',
'James',
'A helpful AI assistant',
'claude-haiku-4-5'
);
if (!agent.id) throw new Error('Agent creation failed');
const result = await appService.processText(agent.id, 'Hello! How are you?');
console.log(result.result);
agent.save(); // persist to disk
// 4. Cleanup when done
await briyah.shutdown();Upgrading to 2.0
2.0 moves the D&D content into a pack. Three things change for a host coming from 1.5; the first two are a line each, and the third is something to know about your data directory.
The D&D prompt folders are now the pack's. dungeon_master, dnd_player,
character_creator, campaign_designer and dnd_room_moderator belong to dnd5e. Enable the
pack for the user, and qualify the folder name:
app.packs.setEnabled('dnd5e', true); // per user, and required
await app.createAgentFromFolder({ folder: 'dnd5e:dungeon_master', /* ... */ });A bare dungeon_master also reaches the pack once it is enabled, so enabling alone is enough if
you would rather not touch the strings. Doing neither is what breaks: in 1.5 those folders sat in
common/prompts and the bare name resolved, and in 2.0 it falls through to the shared root
prompts instead. createAgentFromFolder now refuses that rather than building a generic agent
from it — see Prompt Folders.
enabledToolPacks is gone, replaced by installToolPacks. Most hosts can delete the call
entirely: the Briyah constructor installs every bundled pack's tools already. BRIYAH_TOOL_PACKS
no longer does anything either, and is not read.
init() rewrites three things under your data path. All are stated here because none is
visible until it has happened:
{dataPath}/common/prompts/<bundled folder>is replaced on every init, as are the root prompt files beside them. These are protocol-coupled to the package version —perceive.json's action enum must match the room's actions, andperceive.prompt's EXECUTE and COMMISSION rules must match what the room parses — so a copy left over from an older version silently stops tool calls working. Only what the package ships is touched: a folder of your own sitting incommon/prompts/is left exactly where it is, though a pack is the place for it now.{dataPath}/packs/<bundled pack>/is deleted and re-copied on every init. A pack's directory is package-canonical, so a host that hand-editspacks/dnd5e/loses those edits on the next boot. Edit it as a user instead — the change lands in{dataPath}/user/<userId>/packs/dnd5e/, an overlay that takes precedence — or install your own pack under a name Briyah does not bundle, which is never touched.- Any
common/prompts/<folder>whose name a pack now owns is moved to{dataPath}/retired-prompts/<folder>, and the move is logged as a warning. On a 1.5 data directory that is the five D&D folders. Moved rather than deleted because a leftover copy outranks the pack for a bare name and would silently shadow it. Nothing readsretired-prompts/: keep it if you had local edits worth porting into an overlay, delete it otherwise.
Configuration
BriyahConfigOptions
interface BriyahConfigOptions {
/**
* Base directory for Briyah data storage
* Default: './briyah-data' relative to process.cwd()
*/
dataPath?: string;
/**
* Timeout in minutes for cached user services
* Default: 30 minutes
*/
userServiceCacheTimeoutMinutes?: number;
/**
* Starting balance for new users (in dollars)
* Default: value from process.env.STARTING_BALANCE
*/
startingBalance?: number;
/**
* Native tools to register up front (equivalent to calling registerTool
* for each). See Example 8.
*/
tools?: NativeToolDefinition[];
/**
* Allow end users to author and run their own JS-string tools. Native tools
* are unaffected. Leave off for untrusted multi-user hosts. Default: false.
*/
allowUserAuthoredTools?: boolean;
/**
* Override environment variables
* Useful for testing or multi-instance configurations
*/
envOverrides?: Record<string, string>;
}Environment Variables
LLM Provider API Keys (at least one required):
ANTHROPIC_API_KEY- For Claude modelsOPENAI_API_KEY- For GPT modelsGOOGLE_GENAI_API_KEY- For Google AI (Gemini) modelsXAI_API_KEY- For Grok modelsDEEPSEEK_API_KEY- For DeepSeek modelsTOGETHER_API_KEY- For Together AI modelsFAL_API_KEY- For Fal image-generation models
Self-hosted providers (optional, count toward the "at least one" requirement):
SELF_HOSTED_BASE_URL- OpenAI-compatible inference server for theSelfHostedtext provider (vLLM, Ollama, llama.cpp, LM Studio). Must include the/v1path, e.g.http://gpu-box:8000/v1SELF_HOSTED_API_KEY- Optional bearer token for the aboveCOMFYUI_BASE_URL- ComfyUI server for theComfyUIimage provider, no trailing slash, e.g.http://gpu-box:8188(see Example 9)COMFYUI_EXTRA_HEADERS- Optional JSON object of headers sent on every ComfyUI request, e.g.{"Authorization":"Basic ..."}for a reverse proxyCOMFYUI_TIMEOUT_MS- Total wait for a queued generation, default600000(covers queue wait and model cold start)COMFYUI_POLL_INTERVAL_MS- Poll interval while waiting, default2000
Optional:
STARTING_BALANCE- Starting balance for new users (e.g., "10.00")BRIYAH_DATA_PATH- Global default data path (overridden by constructordataPath)USER_SERVICE_CACHE_TIMEOUT_MINUTES- Cache timeout (overridden by constructor option)ALLOW_USER_AUTHORED_TOOLS-"true"to let users author/run their own tools (overridden by theallowUserAuthoredToolsoption). Default off.
Retired:
BRIYAH_TOOL_PACKS- Read in 1.5 to choose which tool packs to register. It is not read in 2.0 and setting it does nothing. Every bundled pack's tools are registered at construction; what a user can reach is decided by pack enablement and per-agent tool grants. See Packs.
Logging
Briyah writes logs to {dataPath}/logs/briyah.log by default. Configure or disable via the logging option:
const briyah = new Briyah({
dataPath: './my-data',
logging: {
enabled: true, // default
logFile: './my-data/logs/app.log', // default: {dataPath}/logs/briyah.log
console: false, // set true to also log to stdout/stderr
level: 'warn', // 'debug' | 'log' | 'warn' | 'error' — default: 'log'
}
});
// Log to console only (no file):
const briyah = new Briyah({ logging: { console: true, logFile: null } });
// Disable logging entirely:
const briyah = new Briyah({ logging: { enabled: false } });API Reference
Briyah Class
constructor(options?: BriyahConfigOptions)
Creates a new Briyah SDK instance.
const briyah = new Briyah({
dataPath: './briyah-data',
startingBalance: 10.00
});async init(): Promise<void>
Initializes the Briyah system. Must be called before using getAppService().
await briyah.init();getAppService(userId: string): AppService
Gets the AppService instance for a specific user. The AppService provides full access to:
- Agent management
- Room conversations
- Story generation
- Text processing
- File attachments
const appService = briyah.getAppService('user-123');Throws: Error if init() has not been called.
removeUserService(userId: string): void
Manually removes a user's service from the cache. Useful for:
- Forcing a fresh AppService instance
- Cleaning up when a user logs out
briyah.removeUserService('user-123');getCacheStats()
Returns statistics about cached user services.
const stats = briyah.getCacheStats();
console.log(`Cached users: ${stats.cachedUsers}`);async shutdown(): Promise<void>
Shuts down the Briyah system and cleans up resources.
await briyah.shutdown();isInitialized(): boolean
Checks if Briyah has been initialized.
if (briyah.isInitialized()) {
const appService = briyah.getAppService('user-123');
}AppService
The AppService provides full access to Briyah functionality.
Common operations:
// Agent Management
const agent = appService.createAgent(provider, name, nickname, description, model);
const agents = await appService.listAgents();
await appService.deleteAgent(agentId);
await appService.reloadAgent(agentId); // Clear conversation history (keeps any prior summary)
await appService.compactAgentConversation(agentId); // Summarize history into a single message
// Room Management
// roomLeader answers anything the room cannot place; turnMode 'directed' asks only the
// agent a message names ('broadcast', the default, asks every agent every time);
// moderator is only needed when no agent leads the turn order.
const room = await appService.createRoom(
name, goal, agentIds, roomLeader?, turnMode?, moderator?, beginInstruction?,
);
const rooms = await appService.listRooms();
const details = await appService.getRoomDetails(roomId);
// action defaults to 'moderate'. Returns {accepted, reason} — a message sent out of
// turn is refused, not thrown. `sender` must be an agentNickname.
await appService.sendRoomMessage(roomId, content, sender, action?, targets?);
// Opens a room that nobody has spoken in: sends its beginInstruction as a 'System'
// 'speak' addressed to the leader. Throws if the room has no instruction, has no
// agents, or has already begun. See "Starting a room" below.
await appService.beginRoom(roomId);
// Required before subscribing to a room this process did not create.
await appService.ensureRoomStateCallback(roomId);
const emitter = appService.getRoomMessageEmitter(roomId); // 'update' events
// Prompt folder defaults — the role's name, tools and documents, as the create-agent
// dialog would fill them in. Read these rather than hard-coding a pack's tool names.
// `exists` is false for a folder nothing ships, which otherwise looks identical to a
// folder that declares no defaults.
const { defaults, exists } = appService.getPromptFolderDefaults('dnd5e:dungeon_master');
appService.promptFolderExists('dnd5e:dungeon_master'); // the same question, alone
// Or let the folder build the agent: applies those defaults, saves, and attaches the
// folder's documents. Async (attaching is), unlike createAgent. `overrides` takes the
// same shape as the defaults; pass `attachDocuments: false` to decline the documents.
const agent = await appService.createAgentFromFolder({
folder: 'dnd5e:dungeon_master', aiServiceName: 'Anthropic', aiModel: 'claude-sonnet-5',
});
// Agents that belong to a room: created inside it, named after it, deleted with it,
// and left out of listAgents(). agentName is the bare name; the room's is prefixed on.
await appService.createRoomAgent(roomId, { aiServiceName, agentName, description, aiModel, promptName });
await appService.removeRoomAgent(roomId, agentId);
// Native tools (recommended for SDK integrations — see Example 8)
briyah.registerTool({ name, description, parameters, handler }); // process-level
briyah.listNativeTools();
// User-authored (string-code) tools — only when allowUserAuthoredTools is enabled
const tools = await appService.listTools();
const tool = await appService.createTool({ name, description, parameters, code, timeoutMs? });
await appService.updateTool(name, tool);
await appService.deleteTool(name);
const result = await appService.testTool(name, args); // { ok: true, result } | { ok: false, error }
// Story Management
// illustrateStory is required and positional — it is the fifth argument, not an option.
const story = await appService.createStory(
name, idea, userCharacterDesc, otherCharactersDesc, illustrateStory,
storyModel?, isImport?, imageModelName?, skipDetailedPlot?,
);
await appService.progressStory(storyId);
// Text Processing
const result = await appService.processText(agentId, text);
// Image Generation (image-capable providers; called on the Agent object — see Example 9)
const { artifactId } = await agent.generateImage(prompt, imageProperties);
const edited = await agent.editImage(prompt, imageProperties, referenceArtifactIds);
// File Attachments
const attachment = await appService.attachDocument(agentId, fileName, fileData);
await appService.deleteAttachedFile(agentId, fileName); // by filename
await appService.deleteAttachedFileById(documentId); // by document IDPrompt Folders
An agent's behaviour comes from a prompt folder, not from its description. The folder
holds the templates the room renders on every turn — most importantly perceive.prompt, which
tells the agent what it is and what it may do, and perceive.json, the JSON schema its replies
must satisfy. Two agents on the same model with the same description behave completely
differently if they were built from different folders.
init() installs the shared folders into {dataPath}/common/prompts/, so they are on disk before
your first createAgent call. They are replaced on every upgrade — they are coupled to the
package version, and a stale copy stops tool calls working without saying so. Put your own
folders in a pack, which is never overwritten; see Packs.
A folder can also come from a pack — a domain's prompts, documents, tools and templates in one directory. A pack's folders are referenced pack-qualified, and they are only listed once the user has that pack enabled:
const appService = briyah.getAppService('user-123');
console.log(appService.listPromptFolders());
// default, narrator, character, illustrator, pdf_converter, room_moderator, ...
appService.packs.setEnabled('dnd5e', true);
console.log(appService.listPromptFolders());
// ... plus dnd5e:dungeon_master, dnd5e:dnd_player, dnd5e:character_creator,
// dnd5e:campaign_designer, dnd5e:battle_master, dnd5e:dnd_room_moderatorSee Packs — if you are building on dnd5e you have to enable it first, or a bare
folder name will not reach it.
When a folder name is wrong
A prompt lookup always ends at the shared root prompts, so a folder that does not exist does not fail — it produces a working, generic agent. Three things now say so instead:
appService.promptFolderExists('dnd5e:dungeon_master'); // false if the pack is not installed
const { defaults, exists } = appService.getPromptFolderDefaults('dungeon_master');
// exists distinguishes "no such folder" from "a folder that declares no defaults":
// both give you `defaults: {}`. A bare pack folder reports false until the pack is enabled.createAgentFromFolderandcreateRoomAgentFromFolderthrow aNotFoundErrornaming the folder. The whole point of those calls is the folder's recipe — its tools, its documents, its nickname — and a folder that is not there has none.createAgentwarns and carries on, since it takes all of that from its arguments and the folder is only one of them. The warning is in{dataPath}/logs/briyah.log.
The folder is the sixth argument to createAgent, and it defaults to 'default' — a
generic conversational agent. Passing a role folder is what makes the agent that role:
const dm = appService.createAgent(
'Anthropic',
'Dungeon Master', // agentName — a label; used in logs and the UI
'DM', // agentNickname — the name the room routes by
'Runs the game.',
'claude-sonnet-5',
'dnd5e:dungeon_master', // <- the prompt folder
);agentNickname is the identity: messages are addressed to it, artifact viewers are lists of
it, and it is what an agent writes into targets. agentName is a label. Give a character the
nickname you want it called by.
To write your own folder, copy a bundled one out of {dataPath}/common/prompts/ (or a pack's
{dataPath}/packs/<pack>/prompts/), rename the directory, and edit it — a folder in the user data
directory takes precedence over the common one of the same name. Editing a pack's file instead
writes a private copy under {dataPath}/user/<userId>/packs/<pack>/, so one user's change does
not reach the others.
Packs
A pack is one domain's prompt folders, documents, native tools, and agent and room templates
in a single directory. dnd5e is the one that ships: eighteen tools for dice, character sheets,
the rules engine and a bestiary, six prompt folders, sixteen rules documents, and a room template
that builds a whole table.
It exists so a domain arrives as a unit — and so your prompts live in your own directory
rather than beside Briyah's. Install one by copying a directory into {dataPath}/packs/; there is
no import mechanism and nothing to register.
{dataPath}/packs/<name>/
├── pack.json optional: title, description, version
├── prompts/ folders, referenced as <name>:<folder>
├── artifacts/ manifest.json + .md documents
├── agents/ agent templates
└── rooms/ room templatesInstalled for everyone, enabled per user
Tools register process-wide when you construct Briyah. Enabling is per user, and it is what
makes a pack's prompt folders resolve by bare name, its documents appear in listUserArtifacts(),
and its tools grantable:
const appService = briyah.getAppService('user-123');
appService.packs.listPacks();
// [{
// name: 'dnd5e', // <- the id; `title` is for display
// title: 'D&D 5th Edition',
// description: 'Run a fifth-edition tabletop game in a room: ...',
// version: '1.0.0',
// dir: '/srv/app/briyah-data/packs/dnd5e',
// origin: 'host', // 'host' installed it, or 'user' created it
// enabled: false,
// promptFolders: ['battle_master', 'campaign_designer', 'character_creator',
// 'dnd_player', 'dnd_room_moderator', 'dungeon_master'],
// agents: [{ key: 'dungeon_master', promptFolder: 'dungeon_master',
// label: 'Dungeon Master', modelRole: 'primary' }, ...],
// rooms: [{ key: 'table', name: 'D&D Table', goal: '...', turnMode: 'directed',
// agents: [{ key: 'table_moderator' }, ...],
// roomLeader: 'dungeon_master', moderator: 'table_moderator' }],
// documentCount: 16,
// requiresTools: ['apply_hp', 'check', 'roll_dice', ...], // derived from its grants
// requiresPacks: [],
// missingTools: [], // requiresTools this process has not registered
// missingPacks: [], // requiresPacks this user has not enabled
// }]
// The REST API returns a trimmed version of this (`pack` instead of `name`, and a
// `counts` object) — `listPacks()` in-process returns the `PackView` above.
appService.packs.setEnabled('dnd5e', true);Do this before building anything on the pack. A pack-qualified folder
(dnd5e:dungeon_master) always resolves, but a bare dungeon_master only reaches the pack when
it is enabled — otherwise it falls back to the shared root prompts and you get a generic
conversational agent with no error to tell you.
Disabling later hides rather than switches off: the pack leaves the listings, but a room that already exists keeps working, an attached document still loads, and a granted tool still runs.
If you would rather filter or wrap the tool definitions than take them all:
import { dnd5eTools, availableToolPacks } from 'briyah';
const briyah = new Briyah({ tools: dnd5eTools() });
console.log(availableToolPacks()); // ['dnd5e']Building a room from a template
The fastest path to a working table, and the one that gets the details right — the moderator is created before the agents that need one, the leader and moderator are resolved to the nicknames the created agents actually ended up with, and a failure rolls the whole room back:
const { roomId, agentIds, warnings } = await appService.packInstantiation.instantiateRoom(
'dnd5e',
'table',
{
primary: { aiServiceName: 'Anthropic', aiModel: 'claude-sonnet-5' },
economy: { aiServiceName: 'Anthropic', aiModel: 'claude-haiku-4-5-20251001' },
},
'Tuesday Night Game', // optional; auto-suffixed if the name is taken
// optional; per-agent overrides, keyed by the template's own key for the seat
{ player: { agentName: 'Homer', agentNickname: 'Homer' } },
);That is a six-agent room with turnMode: 'directed', the Dungeon Master as leader, a table
moderator, and every agent already carrying its tool grants and rules documents.
Naming the player. The template's player seat ships as PlayerOne, which is not what the
person at the keyboard is called. The fifth argument changes any seat: the keys are the room
template's agents[].key (table_moderator, dungeon_master, battle_master,
character_creator, campaign_designer, player for dnd5e:table), and the value is the same
PromptFolderDefaults shape as a folder's own defaults, applied over them. A key no seat has is
refused before the room is created, rather than quietly doing nothing.
More than one player. The template seats one. Add the others afterwards with
createRoomAgentFromFolder, which is the same call instantiation makes per agent — a room-owned
agent, named after the room, deleted with it:
await appService.createRoomAgentFromFolder(roomId, {
folder: 'dnd5e:dnd_player',
aiServiceName: 'Anthropic',
aiModel: 'claude-haiku-4-5-20251001',
overrides: { agentName: 'Vex', agentNickname: 'Vex', description: 'A wary scout.' },
pack: 'dnd5e', // files it under the pack, so it hides and lists with one
packAgentKey: 'player', // records which template it came from
});Do it after instantiateRoom returns, not during: the room already has its moderator by then, so
a player carrying useModerator: true has one to route through.
A template names a role rather than a model — primary or economy — because a model id goes
stale and assumes your provider keys. The Dungeon Master takes the primary; the agents that do
bookkeeping rather than prose take the economy one. Omit economy and everything uses the
primary. warnings reports anything that did not go to plan without stopping the room, such as a
document that failed to attach; it is worth logging, because a missing document is a capability
the agent appears to have and does not.
For a single agent, with no room:
const { agentId } = await appService.packInstantiation.instantiateAgent(
'dnd5e',
'character_creator',
{ primary: { aiServiceName: 'Anthropic', aiModel: 'claude-sonnet-5' } },
);Building the same room by hand
If you want the pieces rather than the template, registering a pack makes its tools grantable,
not granted. An agent may only run the ones named in its toolNames:
const dm = appService.createAgent(
'Anthropic', 'Dungeon Master', 'DM', 'Runs the game.', 'claude-sonnet-5',
'dnd5e:dungeon_master', // <- pack-qualified
undefined, null, 0, '',
{ toolNames: ['roll_dice', 'check', 'apply_hp', 'stat_block', 'read_sheet'] },
);A pack's documents are read-only rules text an agent carries in every call. They live in
{dataPath}/packs/<pack>/artifacts/, appear in listUserArtifacts() once the pack is enabled,
and attach by an id that never changes:
await appService.attachArtifactToAgent(dm.id, 'pack:dnd5e.dungeon_master-procedure');
await appService.attachArtifactToAgent(player.id, 'pack:dnd5e.class-bard');A document sits in that agent's cached prompt prefix on every call, so attach what it needs and
nothing more. src/tools/packs/dnd5e/README.md in this package lists all eighteen tools and which
agent each document belongs to.
Usage Examples
Example 1: Multi-User Setup
import { Briyah } from 'briyah';
// Initialize once at application startup
const briyah = new Briyah({
dataPath: './data/briyah',
startingBalance: 10.00
});
await briyah.init();
// In your route handler or service
async function handleUserRequest(userId: string, message: string) {
const appService = briyah.getAppService(userId);
// Each user gets their own isolated AppService
const agents = await appService.listAgents();
// ... process user request
}
// Cleanup at application shutdown
process.on('SIGINT', async () => {
await briyah.shutdown();
process.exit(0);
});Example 2: Custom Data Path
import { Briyah } from 'briyah';
import path from 'path';
const briyah = new Briyah({
dataPath: path.join(__dirname, '..', 'user-data', 'briyah'),
userServiceCacheTimeoutMinutes: 60 // 1 hour cache
});
await briyah.init();Example 3: Testing Configuration
import { Briyah } from 'briyah';
const testBriyah = new Briyah({
dataPath: './test-data',
startingBalance: 100.00,
userServiceCacheTimeoutMinutes: 5,
envOverrides: {
ANTHROPIC_API_KEY: process.env.TEST_ANTHROPIC_KEY,
OPENAI_API_KEY: process.env.TEST_OPENAI_KEY
}
});
await testBriyah.init();
// Run tests...
await testBriyah.shutdown();Example 4: Agent Creation and Text Processing
const appService = briyah.getAppService('user-123');
// Create an agent
const agent = appService.createAgent(
'Anthropic', // provider
'AI Assistant', // name
'James', // nickname
'A helpful AI assistant', // description
'claude-haiku-4-5' // model
);
if (!agent.id) throw new Error('Agent creation failed');
console.log(`Created agent: ${agent.id}`);
// Process text with the agent
const result = await appService.processText(
agent.id,
'What is the capital of France?'
);
console.log(`Response: ${result.result}`);
console.log(`Cost: $${result.totalCost}`);
console.log(`Tokens: ${(result.totalInputTokens ?? 0) + (result.totalOutputTokens ?? 0)}`);Example 5: Multi-Agent Room Conversation
const appService = briyah.getAppService('user-123');
// Create agents
const analyst = appService.createAgent('OpenAI', 'Analyst', 'Analyst', 'Data analyst', 'gpt-4');
const writer = appService.createAgent('Anthropic', 'Writer', 'Writer', 'Content writer', 'claude-haiku-4-5');
if (!analyst.id || !writer.id) throw new Error('Agent creation failed');
// Create a room with both agents
const room = await appService.createRoom(
'Analysis Session',
'Analyze sales data and produce a written summary',
[analyst.id, writer.id]
);
// Send a message to start the conversation
await appService.sendRoomMessage(
room.roomId,
'Please analyze the sales data for Q4',
'Analyst',
'speak'
);
// The agents will automatically respond and interact
// Check room messages to see the conversation
const messages = await appService.getRoomMessages(room.roomId);
console.log(`Messages: ${messages.messages.length}`);Example 6: Story Message Emitter
The story message emitter delivers real-time events during gameplay. Subscribe to it after creating the story but before the first player turn, so no events are missed.
import type {
StoryStateEvent,
StoryIntroduceCharacterEvent,
StoryProgressChapterEvent,
StoryErrorEvent,
} from 'briyah';
const app = briyah.getAppService('user-123');
const story = await app.createStory(
'The Lost Kingdom',
'A medieval fantasy where ancient ruins hold a terrible secret',
'A disgraced knight seeking redemption',
'A scholar and a sellsword',
false, // illustrateStory — required, and positional
);
const emitter = app.getStoryMessageEmitter(story.id);
// Fires after every turn with the full updated game state.
emitter.on('story-state', async (event: StoryStateEvent) => {
const { state } = event;
console.log('Latest message:', state.latestRoomMessage?.content);
// Detect the player's turn by comparing speaker names.
// Do NOT use state.humanPrompt for this — it is a UI display string,
// not a reliable turn indicator.
if (state.currentSpeaker === state.userAgentName) {
const input = await promptPlayer(state.humanPrompt);
await app.respondToStory(story.id, input);
}
});
// Fires when the narrator wants to introduce a new character.
// The host app should present this to the player before acting.
// Accept by calling introduceCharacterToStory, or decline with declineCharacter.
emitter.on('suggest-introduce-character', async (event: StoryIntroduceCharacterEvent) => {
const accept = await askPlayer(`Add ${event.characterName} to the story?`);
if (accept) {
await app.introduceCharacterToStory(story.id, event.characterName!, '', undefined, true);
} else {
await app.declineCharacter(story.id, event.characterName!);
}
});
// Fires when the narrator wants to advance to the next chapter.
// Call progressStory to accept. Ignoring the event stays in the current chapter —
// there is no explicit rejection call.
emitter.on('suggest-progress-chapter', async (_event: StoryProgressChapterEvent) => {
const accept = await askPlayer('The narrator suggests ending this chapter. Continue?');
if (accept) {
await app.progressStory(story.id);
}
});
// Fires on fatal errors during room processing.
// Named 'story-error' rather than 'error' to avoid Node.js throwing when no
// listener is attached.
emitter.on('story-error', (event: StoryErrorEvent) => {
if (event.errorType === 'InsufficientBalanceError') {
console.error('Out of credits:', event.message);
} else {
console.error(`Story error [${event.errorType}]:`, event.message);
}
});Example 7: Recording Payments and Managing Balance
Briyah does not process payments itself — your application handles the payment provider interaction. Once a payment is confirmed, use AppService to record the transaction, credit the user's balance, and restart any stories that stalled due to insufficient funds.
const app = briyah.getAppService(userId);
// --- When your payment provider initiates a charge ---
// Record it as pending so the transaction log stays consistent.
await app.recordTransaction(amount, paymentIntentId, 'pending');
// --- When your payment provider webhook confirms success ---
await app.updateTransactionStatus(paymentIntentId, 'succeeded');
await app.addBalance(amount);
// Resume any stories that paused mid-turn due to InsufficientBalanceError.
await app.resumePausedStories();
// --- When a payment fails or is cancelled ---
await app.updateTransactionStatus(paymentIntentId, 'failed');
// --- Querying balance and history ---
const balance = app.getBalance();
const { transactions, total } = await app.getTransactions(50, 0);
const tx = await app.getTransactionByPaymentId(paymentIntentId);
console.log(tx?.status); // 'pending' | 'succeeded' | 'failed' | 'cancelled'To push real-time balance updates to a connected client (e.g. via SSE), subscribe to the balance emitter before crediting the user:
const emitter = app.getBalanceMessageEmitter(userId);
emitter.on('update', (newBalance: number) => {
// Push newBalance to the client
});Example 8: Registering Native Tools with Opaque Context
Native tools are functions you implement in your host code and register once with briyah.registerTool. Grant a tool to an agent by adding its name to agent.toolNames; the agent then calls it on its own (via the EXECUTE room action) when a conversation needs it, chaining several calls privately before it replies.
Each call receives a third argument — an opaque context you pass to sendRoomMessage. Briyah never inspects it; it hands it straight to your handler. Use it for per-interaction data the model must not be able to choose. The declared parameters are what the LLM fills in; scoping data (which record, which tenant, a DB handle) belongs in context, so a confused or adversarial agent cannot target data outside the current interaction.
import { Briyah } from 'briyah';
const briyah = new Briyah({ dataPath: './data/briyah', startingBalance: 10.00 });
await briyah.init();
// Register a tool that reads its scope from context, not from the LLM's args.
briyah.registerTool({
name: 'add_note',
description: "Add a note to the record the user currently has open",
parameters: [
{ name: 'text', type: 'string', description: 'The note text', required: true }
],
handler: async (args, helpers, context) => {
const { db, recordId } = context as { db: MyDb; recordId: string };
await db.addNote(recordId, args.text); // recordId comes from context — the LLM cannot set it
return { added: true };
},
timeoutMs: 15000 // optional; default 30s, max 120s
});
// Grant it to an agent (grants are enforced server-side).
const assistant = app.createAgent('Anthropic', 'Assistant', 'Assistant',
'A helpful assistant that manages the open record', 'claude-haiku-4-5');
assistant.toolNames = ['add_note'];
assistant.save();
// When the user acts on record "rec_42", pass that scope as context. Any tool the
// agent calls during the resulting turn receives it; other rooms/interactions are
// unaffected. A log-only 'tool' message records each call (visible when fetching
// messages with includeThoughts=true).
await app.sendRoomMessage(
roomId,
'Add a note that the client called about renewal.',
'User',
'speak',
[],
false,
{ db, recordId: 'rec_42' } // opaque context
);Because each Briyah userId gets its own isolated AppService, data directory, and balance, mapping one Briyah user per tenant (customer, workspace, org) gives you tenant isolation and per-tenant billing for free; the opaque context then scopes each call within that tenant.
Agents can also delegate: an agent may respond with a commission action naming other agents, each of which researches independently (using its granted tools, with the same context) and replies with a deliver. The commissioner receives all deliverables at once and responds before the conversation continues. No SDK calls are needed for this — it is driven by the agents themselves.
User-authored (string-code) tools remain available via createTool/updateTool/testTool when allowUserAuthoredTools is enabled, for trusted single-user setups.
Example 9: Self-Hosted Image Generation with ComfyUI
The ComfyUI provider generates images on a self-hosted ComfyUI server. It is image-only: generateImage/editImage work, textPrompt throws. Set COMFYUI_BASE_URL to enable it. ComfyUI has no authentication of its own — keep it on a LAN/VPN or behind an authenticated reverse proxy (pass proxy credentials via COMFYUI_EXTRA_HEADERS).
For this provider the agent's model name selects a workflow-template pair, not a checkpoint. Two pairs ship by default:
| Pair name | Model | Files required on the ComfyUI server |
|---|---|---|
| flux2-dev | FLUX.2 dev (FP8 mixed) | diffusion_models/flux2_dev_fp8mixed.safetensors, text_encoders/mistral_3_small_flux2_bf16.safetensors, vae/flux2-vae.safetensors |
| flux-kontext | Flux.1 Kontext dev (FP8) | diffusion_models/flux1-dev-kontext_fp8_scaled.safetensors, text_encoders/clip_l.safetensors + t5xxl_fp8_e4m3fn_scaled.safetensors, vae/ae.safetensors |
flux2-dev has substantially better multi-reference character consistency and wants a 32 GB-class GPU; flux-kontext runs on 16 GB-class cards.
import { Briyah } from 'briyah';
const briyah = new Briyah({ dataPath: './data/briyah', startingBalance: 10.00 });
await briyah.init();
const app = briyah.getAppService('user-123');
// Model name = workflow pair name
const artist = app.createAgent('ComfyUI', 'Artist', 'Artist',
'Image generation agent', 'flux2-dev');
// Text-to-image. Resolves to { artifactId } on success or { error } on failure.
const generated = await artist.generateImage(
'A lighthouse on a rocky coast at dusk',
{ width: 1024, height: 1024 } // optional; a fixed seed may also be passed
);
if (generated.error) throw new Error(String(generated.error));
// Generated images are stored as artifacts (PNG)
const png = artist.artifactService.getArtifact(generated.artifactId);
// Image-to-image with reference images. Each referenced artifact is uploaded
// and conditioned on separately (chained ReferenceLatent nodes). Order is
// preserved, so the prompt can refer to "Image 1", "Image 2", ...
const edited = await artist.editImage(
'The character from Image 2 stands in the setting from Image 1',
{ width: 1024, height: 1024 },
[sceneArtifactId, portraitArtifactId]
);Notes:
- Cost: generation is free by default — no balance is checked or decremented. To charge users, pass
centsPerImagein the image properties; the balance gate then applies. - Latency: the GPU processes one job at a time and the first generation after server start loads the model weights.
COMFYUI_TIMEOUT_MS(default 10 minutes) covers queue wait plus cold start. - Stories: to make a pair selectable in the story UI's image-model dropdown, add an entry to
{dataPath}/common/config/image_models.jsonwith"service": "ComfyUI"and the pair name as"model"in thegenerationandeditingblocks, with"centsPerImage": 0.
Custom workflows. Any ComfyUI workflow can be used: build it in the ComfyUI web UI, run it once, then use Export (API format) and replace the values you want parameterized with tokens — {{prompt}} (may be embedded in a longer string), {{seed}}, {{width}}, {{height}} (replaced as numbers), and {{ref_image}} (edit workflows, the LoadImage input). Save the files as {name}.t2i.json (used by generateImage) and {name}.edit.json (used by editImage) in {dataPath}/common/config/comfyui-workflows/, or in {dataPath}/user/{userId}/comfyui-workflows/ for a per-user override (checked first). The pair name becomes the model name. For multi-reference support, an edit workflow must contain exactly one ReferenceLatent node fed by a VAEEncode fed by a LoadImage; extra references are added at request time by cloning that chain.
Example 10: A turn-based table with a human player
Example 5 sends one message and reads the transcript back, which suits a batch job. A game or any human-in-the-loop app needs the other shape: the agents take several turns among themselves, then stop and wait for the person. Three things make that work.
turnMode: 'directed' — only the agent a message names is asked to reply, and the room
leader answers anything it cannot place. The default, 'broadcast', prompts every agent for
every message and keeps one reply, which costs several times as much and is wrong for a table
where one person speaks at a time.
A human-controlled agent — createAgent's seventh argument. The room delivers messages to
it and then stops rather than generating a reply, which is what yields the floor to your console.
The room message emitter — the only way to see the conversation as it happens. It fires on every change with the newest message and the room's state.
Starting a room. A room is pull-driven: an agent is prompted only when a message arrives,
so a room nobody has spoken in never moves however complete its configuration. Give the room a
beginInstruction and call beginRoom(roomId), and it goes to the leader as a 'System'
'speak' — which is how a leader opens a turn-based table without the person having to tell it
to. The web client offers it as a Begin button while the transcript is empty. It refuses a
room that has already begun, so it is safe to call from several places at once.
import { Briyah } from 'briyah';
import type { RoomUpdate } from 'briyah';
import * as readline from 'node:readline/promises';
// The constructor installs every bundled pack's tools already; pass `tools` only to
// filter or wrap them, or to add your own.
const briyah = new Briyah({ dataPath: './briyah-data' });
await briyah.init(); // seeds prompt folders and the packs
const app = briyah.getAppService('user-123');
// Enable the pack for this user, or a bare folder name will not reach it. Enabling is
// per user; installing is not.
app.packs.setEnabled('dnd5e', true);
const dm = app.createAgent(
'Anthropic', 'Dungeon Master', 'DM', 'Runs the game.', 'claude-sonnet-5',
'dnd5e:dungeon_master', undefined, null, 0, '',
{ toolNames: ['roll_dice', 'check', 'apply_hp', 'stat_block', 'read_sheet'] },
);
const rogue = app.createAgent(
'Anthropic', 'Vex', 'Vex', 'A wary scout.', 'claude-haiku-4-5', 'dnd5e:dnd_player',
);
// The seventh argument is what makes this one wait for a person.
const you = app.createAgent(
'Anthropic', 'Homer', 'Homer', 'A hopeful bard.', 'claude-haiku-4-5', 'dnd5e:dnd_player',
true,
);
// createAgent builds the agent in memory; save() writes it to disk. Without this the
// table exists for this process only and is gone on restart.
[dm, rogue, you].forEach((agent) => agent.save());
await app.attachArtifactToAgent(dm.id!, 'pack:dnd5e.dungeon_master-procedure');
await app.attachArtifactToAgent(rogue.id!, 'pack:dnd5e.class-rogue');
await app.attachArtifactToAgent(you.id!, 'pack:dnd5e.class-bard');
const { roomId } = await app.createRoom(
'The Sunken Barrow',
'A three-room dungeon crawl.',
[dm.id!, rogue.id!, you.id!],
'DM', // roomLeader — answers anything the room cannot place
'directed', // turnMode
);
// A room loaded from disk has no state-change callback, so its emitter would never
// fire. Harmless on a room you just created; required for one you resumed.
await app.ensureRoomStateCallback(roomId);
const io = readline.createInterface({ input: process.stdin, output: process.stdout });
let lastIndex = -1;
let atKeyboard = false; // the handler is async; without this it re-enters
let replyTo = 'DM'; // who last spoke to the player
let replyAs: 'speak' | 'whisper' = 'speak';
app.getRoomMessageEmitter(roomId).on('update', async (state: RoomUpdate) => {
if (state.type === 'error') { // a turn failed — see "Errors" below
console.error(`[${state.errorType}] ${state.message}`);
return;
}
const message = state.latestMessage;
if (message && state.messageIndex !== undefined && state.messageIndex > lastIndex) {
lastIndex = state.messageIndex; // the emitter can repeat a state
// Everything the room logged comes down this stream, an agent's thinking
// and its tool calls included. Nobody at the table hears either, and a DM
// thinking out loud is the adventure with its answers written in.
const audible = message.action !== 'think' && message.action !== 'tool';
if (audible && message.sender !== 'Homer') {
console.log(`${message.sender}: ${message.content}`);
// Answer whoever addressed you, the way they addressed you. During
// session zero that is the designer or the creator, whispering —
// replying to the DM instead drops the interview.
if (message.targets?.includes('Homer')) {
replyTo = message.sender;
replyAs = message.action === 'whisper' ? 'whisper' : 'speak';
}
}
}
// The floor is the player's only when the room *names* them. `waitingForHuman`
// is also true for a moment after sendRoomMessage clears currentSpeaker and
// before processing starts, so gating on it alone opens a second prompt before
// the table has said a word.
if (!atKeyboard && state.currentSpeaker === 'Homer') {
atKeyboard = true;
try {
const line = await io.question('> ');
const result = await app.sendRoomMessage(roomId, line, 'Homer', replyAs, [replyTo]);
if (!result.accepted) console.log(`(not your turn — ${result.reason})`);
} finally {
atKeyboard = false;
}
}
});
// The emitter only fires on a *change*, so the opening line comes from here.
await app.sendRoomMessage(roomId, 'Begin the adventure.', 'Homer', 'speak', ['DM']);The emitter payload is RoomUpdate, a two-member union discriminated by type —
RoomMessageUpdate (type: 'state') and RoomErrorUpdate (type: 'error'). Both are exported,
so the narrowing above compiles under strict with nothing hand-written. A state update carries
latestMessage, messageIndex, totalCost, isPaused, currentSpeaker,
processingInProgress and waitingForHuman. A state update carries every message the room
logged — thoughts and tool notices included — and messageIndex is that message's own index, so
it lines up with getRoomMessages(roomId, 0, true) and a gap between the two is a real gap
rather than a difference of bookkeeping. Drop what a host does not want on action, as the loop
above does; asking the room for a narrower list instead leaves the two counting differently, and
nothing in the update can say by how much.
Errors arrive on the same emitter, as {type: 'error', errorType, message, timestamp}. Same
event name rather than 'error', because an EventEmitter throws when an 'error' event has no
listener and that would take down a host that subscribed only to updates. Check for it first, as
above: a turn that dies — most often on a spent balance — otherwise just goes quiet, with no
message and nothing to await.
waitingForHuman means "the human may send something", not "the human is being asked
something". It is true while a human holds the floor, and also true for an idle room — no
speaker, nothing queued, nothing in flight — which includes the gap between sendRoomMessage
accepting a line and processing starting. Gate a prompt on it alone and you open a second one
before the table has said a word. currentSpeaker === <the human's nickname> is the test for
"it is their turn", which is what the loop above uses; waitingForHuman is the right test for
whether to enable a composer.
sendRoomMessage's action defaults to 'moderate', not 'speak'. Pass the action you
mean. A message sent out of turn is refused rather than thrown: the result is
{accepted: false, reason}, which is what to show as "not your turn".
The sender string must be an agentNickname — that is the name the room routes by.
The replyTo/replyAs bookkeeping is only for a table with no moderator. This room has
none, so the player's line has to name its own recipient. Give the human agent
useModerator: true in a room that has a moderator and all of it goes away — the room forces the
message to moderate, clears the targets, and the moderator decides who answers. That is how the
dnd5e room template ships; see Example 11.
Two notes for a long-running process. Call await briyah.shutdown() before exiting or it will
hang on open handles. And a room with no human-controlled agent never stops on its own: the
agents talk until the leader adjourns.
Example 11: Seating a whole D&D table
Example 10 seats a Dungeon Master and players, which is enough to see the loop but not enough to
play. The dungeon_master prompt whispers a campaign designer to build the world and a
character creator to build sheets, and hands fights to a battle master, so a table
without those three has a leader whose own instructions reference agents that are not there.
The pack ships that table as a room template, and instantiating it is the whole of the setup:
import { Briyah } from 'briyah';
const briyah = new Briyah({ dataPath: './briyah-data' });
await briyah.init();
const app = briyah.getAppService('cli-user');
app.packs.setEnabled('dnd5e', true); // per user, and required
const { roomId, warnings } = await app.packInstantiation.instantiateRoom(
'dnd5e',
'table',
{
primary: { aiServiceName: 'Anthropic', aiModel: 'claude-sonnet-5' },
economy: { aiServiceName: 'Anthropic', aiModel: 'claude-haiku-4-5' },
},
'Campaign — Homer',
{ player: { agentName: 'Homer', agentNickname: 'Homer' } },
);
warnings.forEach((warning) => console.warn(warning));That is six agents — moderator, Dungeon Master, battle master, character creator, campaign
designer, and Homer at the keyboard — each with its folder's tool grants and rules documents,
turnMode: 'directed', the Dungeon Master leading, and the moderator created before the agents
that route through it. Then run the loop from Example 10.
The goal. The template's goal describes the game in general. A first session usually wants one that says what has not happened yet, or the Dungeon Master opens a scene for characters who have no sheets:
await app.editRoom(roomId, 'Campaign — Homer',
'A fifth-edition campaign for Homer. Nothing is prepared: no campaign has been designed ' +
'and no character has a sheet. Run session zero first — have the Campaign Designer ' +
'interview Homer and publish the Campaign document, then hand the Character Creator one ' +
'player at a time until every sheet exists — and only then open the first scene.');Models by role. The Dungeon Master reasons and makes every tool call, and is the only agent
the template puts on the primary model. Everyone else is economy, including the campaign
designer, whose output is long but infrequent:
| Role | modelRole | Suggested |
|---|---|---|
| Dungeon Master | primary | claude-sonnet-5 |
| Battle master, character creator, campaign designer, moderator | economy | claude-haiku-4-5 |
| Players, human and AI | economy | claude-haiku-4-5 |
Pass only primary and everything runs on it.
The moderator, and what it saves your loop. The shipped table seats one
(dnd5e:dnd_room_moderator), and the player template sets useModerator: true. That pairing is
not decoration: when a human agent has the flag, the room forces their message to moderate and
clears its targets — server-side, whatever action you passed — and the moderator decides who
hears it and who answers. A person typing a line does not know whether they are answering the
designer's interview question or the DM's "what do you do", and with a moderator they do not have
to: the replyTo/replyAs mirroring in Example 10 exists only because that table has none.
Skip the moderator when nothing addresses the player except the leader. Then the Dungeon Master
names who acts next in a directed room, which is the same job for less money. If any agent has
useModerator: true, the room must name a moderator or createRoom raises a ValidationError.
Building it by hand. If you want the pieces rather than the template, createAgentFromFolder
reads a folder's defaults_config.json and applies all of it — the same wiring the web client's
create-agent dialog performs — so you never transcribe eighteen tool names or thirteen document
ids, and nothing drifts when the pack changes:
const from = (folder: string, aiModel: string, overrides = {}) =>
app.createAgentFromFolder({ folder, aiServiceName: 'Anthropic', aiModel, overrides });
const mod = await from('dnd5e:dnd_room_moderator', 'claude-haiku-4-5');
const dm = await from('dnd5e:dungeon_master', 'claude-sonnet-5');
const marshal = await from('dnd5e:battle_master', 'claude-haiku-4-5');
const designer = await from('dnd5e:campaign_designer', 'claude-haiku-4-5');
const creator = await from('dnd5e:character_creator', 'claude-haiku-4-5');
const you = await from('dnd5e:dnd_player', 'claude-haiku-4-5', {
agentName: 'Homer', agentNickname: 'Homer',
description: 'A player character with no sheet yet, played by a person.',
controlledByHuman: true, useModerator: true,
});
const { roomId } = await app.createRoom(
'Campaign — Homer',
'A fifth-edition campaign for Homer. ...',
[mod.id!, dm.id!, marshal.id!, designer.id!, creator.id!, you.id!],
'DM', // roomLeader
'directed', // turnMode
'Moderator', // moderator — required, because Homer has useModerator
);The nicknames come from the folders: Moderator, DM, Marshal, Designer, Creator. These
agents are free-standing rather than room-owned, so they appear in listAgents() and outlive the
room; createRoomAgentFromFolder is the room-owned equivalent, and is what the template uses.
Resuming. listRooms() gives saved campaigns; getRoomDetails(roomId) gives the roster,
where agents[].controlledByHuman identifies the person's character. Then
ensureRoomStateCallback(roomId) before subscribing — without it the emitter on a resumed room
never fires.
Data Storage
Briyah stores data in the following structure:
{dataPath}/
├── common/ # Shared across all users
│ ├── config/ # LLM pricing and image-model config
│ ├── prompts/ # Global prompt templates
│ └── published-agents.json # Published agent mappings
├── packs/ # Installed packs, one directory each
│ └── {pack}/ # pack.json, prompts/, artifacts/, agents/, rooms/
├── retired-prompts/ # Folders a pack took over; see "Upgrading to 2.0"
├── logs/ # briyah.log
└── user/
└── {userId}/ # Per-user data
├── agents/ # Agent configs and histories (one JSON per agent)
├── rooms/ # Room metadata, message logs, artifacts (one folder per room)
├── stories/ # Story state
├── tools/ # User-defined tools (one JSON per tool)
├── prompts/ # User-specific prompts
├── packs/{pack}/ # This user's edits to a pack, or a pack they created
├── artifacts/ # This user's own documents
├── upload/ # User file attachments
├── transactions/ # Transaction records
└── userconfig/ # User preferences, balance, and enabled packsinit() seeds common/ additively, replaces everything it ships under common/prompts/, and
replaces every bundled pack under packs/ — see Upgrading to 2.0 for
exactly what is rewritten and where your own edits belong instead.
Model pricing
createAgent refuses a model that has no pricing entry. That is deliberate: without a price, the
call is costed at zero and balance gating never fires, which is worse than a clear error.
Prices live in {dataPath}/common/config/model_prices.json, a copy of
LiteLLM's table used under the MIT Licence — see
LICENSE-model-prices.md beside it. That file is replaced on every init(), so an edit to it
does not survive.
To use a model it does not list — one a provider announced since the last Briyah release — or to
record a rate that is not list price, add model_prices.local.json in the same directory:
{
"gpt-5.2-turbo": {
"litellm_provider": "openai",
"mode": "chat",
"input_cost_per_token": 0.0000025,
"output_cost_per_token": 0.00001
}
}Seeding never writes that file, so it survives upgrades. Entries are merged over the bundled table by model name, and an entry replaces its bundled counterpart outright rather than being merged field by field. A malformed file is logged and ignored rather than fatal. The table is read once per process, so a new entry needs a restart.
Multi-Instance Support
You can create multiple Briyah instances with different configurations:
const briyah1 = new Briyah({ dataPath: './data1' });
const briyah2 = new Briyah({ dataPath: './data2' });
await briyah1.init();
await briyah2.init();
// Each instance has isolated data
const user1Service = briyah1.getAppService('user-123');
const user2Service = briyah2.getAppService('user-123');
// These are completely separate services with different data pathsNote: Singleton services (LLM providers, message emitters) are shared across all Briyah instances within the same process. This is safe because they are stateless or keyed by ID.
Troubleshooting
"Must call init() before getAppService()"
Make sure to call await briyah.init() before calling getAppService().
"No LLM provider API keys configured"
Set at least one of the required API key environment variables (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.).
"STARTING_BALANCE environment variable is required"
Set the STARTING_BALANCE environment variable or pass startingBalance in the constructor options.
Cache not working as expected
Check the cache statistics with getCacheStats() and adjust userServiceCacheTimeoutMinutes in the constructor options.
