Developer Reference
APIs for agent authors, player implementors, and character consumers.
- Architecture Overview
- The Character Package
- Part 1 — Agent Authors
- Part 2 — Player Implementors
- Part 3 — Character Consumers
This chapter is for developers. It covers three distinct roles in the Character Studio ecosystem and the API contracts each one depends on. If you are writing an agent to extend a character, you want Part 1. If you are building a player — a library or application that runs Character Studio characters — you want Part 2. If you are embedding a character into a website or application using an existing player, you want Part 3.
Character Studio implements all three roles itself. Studio is both the editing environment and a full player, and the Character Player and PartyLine are standalone players that run the same portable character packages. All of our players send model calls through an API key the user provides. That works for individuals, but many AI companies don't let subscribers spend their plan's included usage through an API key. So the way to let a company's subscribers play characters on the allowance they already pay for is for the company to run a player inside its own site, wired to its own sign-in and model access. This chapter is written for that integration. The APIs here are the contracts that let a character built in Studio run unchanged in any conformant player, whether it's ours or one a company builds or adapts from our reference implementation.
These APIs are defined by their behavior contracts, not by any specific implementation. A player built in React, a player built in Swift, and a server-side player built in Python can all be conformant implementations of the bridge and player APIs described here — as long as they honor the contracts, characters and agents will work in all of them.
Architecture Overview
Three layers work together to run a character:
┌─────────────────────────────────────────────┐
│ Consumer (website, app, or Studio itself) │
│ Calls the Player API to send messages │
│ and receive responses │
└────────────────────┬────────────────────────┘
│ Player API
┌────────────────────▼────────────────────────┐
│ Player (the runner) │
│ Manages the conversation, calls the LLM, │
│ brokers tool calls, runs agents │
└────────────────────┬────────────────────────┘
│ Bridge API
┌────────────────────▼────────────────────────┐
│ Agents (JS/TS, Python, or Lua modules │
│ in the character package) │
│ Implement tools the model can call, │
│ maintain persistent state per conversation │
└─────────────────────────────────────────────┘
A character package is the portable artifact that moves between these layers. It contains everything the player needs to run a character: the text files, the configuration, and all the agent modules the character depends on, in whichever of the four agent languages (JavaScript, TypeScript, Python, Lua) they were written. Packages are self-contained — the player does not need to know which specific agents a character uses in advance.
The Character Package
A character package is a directory (or archive) with the following structure. File paths shown are defaults — all character content files and the context directory can be renamed by the character author via fileMappings in config.json. Players must resolve paths through fileMappings before reading any file. See the fileMappings section below.
my-character/
config.json ← Character configuration (never remapped)
instructions.txt ← Character definition (default name)
system-prompt.txt ← System prompt template (default name)
description.txt ← Short description (default name)
intro.txt ← Opening message (default name)
context/ ← Additional context files (default directory name)
*.txt
agents/ ← Agent modules — path is fixed, not remappable
namespace.agent-name.js ← JavaScript (TypeScript arrives here compiled)
namespace.other-agent.py ← Python, shipped as source
namespace.third-agent.lua ← Lua, shipped as source
name.subagent.md ← Sub-agent prompt file
...
lib/ ← Pre-built JS libraries declared with @lib
library-name.js
...
namespace.agent-name.agent.json ← Default agent state (optional, per agent)
config.json
The character configuration file. Relevant fields:
{
"name": "Aria",
"model": "model-id",
"agents": [
"studio.memory-store",
"my-character.battle-manager",
{ "id": "aria.lore", "implementation": "studio.context-access",
"contextFile": "lore.txt" }
],
"fileMappings": {
"Instructions": "eve-character-sheet.txt",
"System Prompt": "prompts/system.txt",
"Description": "pub/description.txt",
"Introduction": "pub/intro.txt",
"ContextDirectory": "world-lore/"
}
}
The "agents" array lists agents in load order. Load order is registration priority: the first agent to register a tool name or command name wins; later agents are silently skipped for any collision. Each entry is either a string or an object:
- A string names an agent file in the package's
agents/directory. The player looks for{name}.js, then{name}.py, then{name}.lua, and loads the first one it finds. (Our players also check{name}.tsafter.jsand compile it at load, because they can play a Studio project directly before export. An exported package never contains.ts.) Built-instudio.*agents resolve the same way, because export copies them into the package. A string ending in.subagent.mdis a sub-agent shorthand: the player reads that file fromagents/and registers it as a sub-agent. - An object is a composed entry — see below.
Composed agent entries
An object entry creates a named instance of an implementation, with its own configuration and its own store. Two kinds exist today: composable lookup agents bound to a data file, and sub-agents with wiring.
{ "id": "char.cars-db",
"implementation": "studio.agent-db",
"contextFile": ".cars.json" }
{ "id": "eve.lorekeeper",
"implementation": "studio.sub-agent",
"promptFile": "lorekeeper.subagent.md",
"history": "recent:10",
"model": "main",
"shared-agents": ["studio.history-search"],
"private-agents": ["studio.memory-store"] }
idis the instance's registered ID — it becomescontext.agentId, keys the instance's store, and derives its tool name (char.cars-db→cars_db).implementationis the agent file to load, resolved exactly like a string entry.- Every other field is passed to the agent unchanged as
context.config. A player doesn't interpret these fields for ordinary agents; the agent does. Lookup agents readcontextFileand fetch it throughstudio.context.readFile. "implementation": "studio.sub-agent"entries are the exception: the player itself readspromptFile(relative toagents/), the per-instance overrides, and the two grant lists. See Sub-agents and granted tools below.
User-facing details for both kinds are in Chapter 7 (lookup agents), Chapter 8 (sub-agents), and Chapter 9 (the full agents syntax).
fileMappings — required player support
Character authors can rename any of the standard content files and the context directory. A conformant player must apply these mappings before reading any character file. Assuming default file names is not valid.
All paths in fileMappings must be treated as relative to the character package root — the directory containing config.json. Players must reject or sanitize any path that attempts to escape the package, specifically paths containing .. or beginning with /. In web-based players using FSA or OPFS, the browser's file system APIs enforce this automatically. Standalone and server-side players are responsible for their own enforcement.
| Key | Default path | What it points to |
|---|---|---|
"Instructions" | instructions.txt | Character definition file |
"System Prompt" | system-prompt.txt | System prompt template |
"Description" | description.txt | Short character description |
"Introduction" | intro.txt | Opening message shown before first turn |
"ContextDirectory" | context/ | Directory containing context files |
Keys not present in fileMappings retain their defaults. config.json itself and the agents/ directory are never remapped — their paths are always fixed.
The recommended player implementation is a path resolver built immediately after loading config.json:
const DEFAULTS = {
"Instructions": "instructions.txt",
"System Prompt": "system-prompt.txt",
"Description": "description.txt",
"Introduction": "intro.txt",
"ContextDirectory": "context",
};
function resolvePath(key, config) {
return config.fileMappings?.[key] ?? DEFAULTS[key];
}
// Usage:
const instructionsPath = resolvePath("Instructions", config);
const contextDir = resolvePath("ContextDirectory", config);
All subsequent file reads go through this resolver. A player that hard-codes instructions.txt will silently serve wrong content for any character that has renamed its files — which is a source of confusing, hard-to-diagnose failures.
When a character is exported for distribution, any built-in platform agents it uses must be copied into the package's agents/ directory. This makes the package self-contained: the player does not need to know about any specific built-in agents, and version mismatches between the authoring tool and the player cannot affect the character's behavior. The player simply loads whatever agent files are present in the package.
Default agent state
If a file named {agentId}.agent.json exists in the package root, the player uses it as the initial state for that agent in any new conversation. This allows a character to ship with pre-configured starting data — initial memories, starting stats, a pre-populated NPC roster. Existing conversations are never affected when this file changes.
System prompt macros
The system prompt template supports one macro that the player expands before sending to the model:
%%CHARACTER_INSTRUCTIONS%%— replaced with the contents of the instructions file (resolved viafileMappings)
Character context is appended after the expanded system prompt as a single block. The recommended (but not required) method of including the context is to write a simple prefix of "The following information is provided as background context for this character. It is not always relevant. Only refer to it if it's relevant to the discussion:". Each context file should be separated by two carriage returns. The entire context block should be sent with the role: "user" and isFile: true.
The Agent preload context (see onLoad below) is appended after the character context block and should also be sent with the role: "user" and isFile: true.
Part 1 — Agent Authors
An agent is a JavaScript, TypeScript, Python, or Lua module that extends a character with persistent state and model-callable tools. The four languages are full peers — one contract, one bridge API. This section covers the contract in its JS form and notes the Python and Lua differences where they exist.
The manifest
Every agent file begins with a structured block that declares its capabilities. The player parses this block before executing any code. In JS/TS it is a /** ... */ comment; in Python it is the module docstring with the identical tag vocabulary (tag lines need no leading *); in Lua it is a top-of-file --[[ ... ]] block comment with the same tags (plain --[[ ]] only — long-bracket forms are not recognized):
"""
@agent namespace.agent-name
@description Human-readable description shown in player UIs
@tool tool_name
@preload true
"""
/**
* @agent namespace.agent-name
* @version 1.0
* @description Human-readable description shown in player UIs
*
* @tool tool_name
* @tool-description What the model should call this tool for
* @tool-params {"type":"object","properties":{...},"required":[...]}
*
* @command @cmd handlerExport
*
* @preload true
* @api studio.history
* @lib jmespath
* @debug true
*/
| Field | Required | Description |
|---|---|---|
@agent | Yes | Unique agent ID. Use a namespace prefix that identifies you: mysite.battle-manager. The studio. prefix is reserved for platform built-ins. |
@version | No | Semver string. Informational. |
@description | No | Shown in player UIs and store panels. |
@tool | 0+ | Declares a tool the model can call. Each @tool block spans one @tool, one @tool-description, and one @tool-params. |
@tool-description | No | One sentence describing when the model should call this tool. Sent directly to the model in the tool definition. |
@tool-params | No | JSON Schema object describing the tool's parameters. |
@command | 0+ | Declares a direct command and the exported function that handles it. Format: @command @name exportedFn. |
@preload | No | true to call onLoad at conversation start. Default: false. |
@api | 0+ | Declares a bridge API namespace this agent uses, one per line: studio.history, studio.context, studio.model, studio.grants, or studio.web. Enforced by the sandbox — a call into an undeclared namespace rejects with an error naming the missing @api line. studio.toast and studio.log need no declaration. |
@lib | 0+ | Declares a pre-built JavaScript library dependency from agents/lib/. Each @lib name resolves to agents/lib/name.js (falling back to name.min.js). The library is evaluated in the agent's execution context before the agent module loads, registering itself on the global scope. Only IIFE/UMD format libraries are supported — ES modules are not. JavaScript/TypeScript agents only: a Python or Lua agent declaring @lib gets a load warning and no libraries. See Library Files below. |
@hook | 0+ | Asks the player to call the agent when an event happens. One line per event: @hook conversationStart, @hook beforeResponse, @hook afterResponse, @hook afterToolCall. The agent must also export the handler (onConversationStart, onBeforeResponse, onAfterResponse, onAfterToolCall). Unknown event names are ignored with a warning. See Hook events below. |
@debug | No | true to enable per-conversation activity logging for this agent. When active, Studio writes a timestamped record of every tool call and direct command (args, result, duration) to a sidecar log file, and surfaces it in the Agent Log panel in the chat toolbar. Default: false. Use during development; omit or set to false for distribution. |
Exported functions
An agent exports functions that the player calls at the appropriate times. All functions share the same parameter shape; unused parameters can be omitted from the signature. In Python, the exports are the module-level functions: onLoad is on_load, onToolCall is on_tool_call, and @command handlers use the exact name declared in the manifest. Python functions may be async def or plain def; return-value semantics are identical, with None standing in for null/undefined. Lua follows the same mapping with its global functions (on_load, on_tool_call, verbatim command handlers); handlers are plain functions, and nil stands in for null.
onLoad(store, studio, context)
Called at conversation start if @preload true. Return a string to inject as additional context before the first model turn, or null if there is nothing to inject. Use this to surface current agent state — stored memories, active stats, tracked NPCs — so the model begins the conversation already aware of them.
The player collects all non-null return values from all preloading agents, concatenates them, and sends the result as a user message with isFile: true, after the character context block (see System prompt macros above). It is not appended to the system prompt.
onToolCall(toolName, args, store, studio, context)
Called when the model emits a tool call for a tool this agent owns. toolName is the tool name string. args is a plain object of the parsed arguments. Return a string — this becomes the tool result sent back to the model. The player handles appending the result to the conversation and calling the model again.
Named command handlers
Each @command declaration maps a command token to a named exported function:
// @command @memory memoryAdd
export async function memoryAdd(args, store, studio, context) {
// args is the string after the command token, unparsed
// return a string to signal a validation error (dialog stays open)
// return nothing (undefined) on success
}
Command handlers are invoked directly by the user through the command dialog, without a model call. They may use studio.log() and studio.toast() to give feedback. A returned string is treated as a validation error — the player should display it and keep the dialog open so the user can correct the input.
Hook events
A hook lets an agent react to something that happened instead of waiting for the model to call one of its tools. Declare each event with @hook in the manifest and export the matching handler; the handler is on plus the event name capitalized. In Python and Lua the names are snake_case: on_conversation_start, on_before_response, on_after_response, on_after_tool_call.
/**
* @agent mychar.scene-tracker
* @preload true
* @hook afterResponse
*/
export function onAfterResponse(event, store, studio, context) {
// event: { userText, responseText, toolCalls: [{ name, args, result }], stopped }
store.turns = (store.turns ?? 0) + 1;
}
| Event | When it fires | event payload |
|---|---|---|
conversationStart | The first time the agent's store is created for a conversation (including an agent added to a conversation already under way), before its first onLoad. | isNewConversation (false when the conversation already had user messages) |
beforeResponse | After the user sends, before the model request is built. In a party, before each member's call, for that member's agents and the shared set. | userText, regenerate (true when the same message is being answered again), respondingToHuman (false when a party member is joining bot-to-bot chatter) |
afterResponse | After the character finishes each response, once its whole tool chain is done. In a party, only the speaking member's agents (and the party-shared set) are called. | userText (the user message the response follows), responseText, toolCalls (this turn's calls, arguments parsed, each with its toolCallId), stopped (true if the user stopped it partway or it errored), toolsOffered (false if the model had no agent tools this turn) |
afterToolCall | After another agent's tool finishes a call the model made. Never for the agent's own tools, never after a failed call. | agentId, toolName, args, result, toolCallId |
afterUserEdit | When a user edit takes tool calls out of the history (Truncate, Delete, Resubmit, Save & Resubmit, Regenerate, Mark Unacceptable / Reject), before any regenerated response is requested; and after a fork, in the new conversation, for the calls past the fork point. Only when at least one tool call went. In a party, each member's agents get only the calls from that member's turns; party-shared agents get all of them. | deletedToolCallIds (ascending), lastViableToolCallId (the highest id still in the history, not counting rejected responses; 0 if none) |
The handler's return value is ignored. Hooks change the agent's own store (and may use studio.log / studio.toast); the change reaches the model through onLoad, which the player rebuilds before the next request. An afterToolCall handler observes only: the other agent's result has already gone to the model, and one agent can never change another's store. context.invokedBy is "hook", and a studio.callModel call from a hook counts against the user's agent token caps just like a model-initiated call.
A player may not support hooks, and a hook that throws or times out is skipped without interrupting the conversation. Write agents so they work correctly even if their hooks never run — hooks make an agent more responsive, never correct.
Note that user-entered commands are useful to manage the model's interactions with agents, but may not be useful for all player situations. You may decide not to implement these handlers in your player, but it is recommended. One common use case for these commands is to update data that the model failed to update (update an event, add a new event, update a time value). In any case, these command handlers being skipped will not change how models interact with agents.
The store object
The store parameter is a plain, JSON-serializable object that belongs entirely to the agent. Its structure is defined by the agent — the player does not interpret it. The player loads the store from persistent storage before each call and saves it back after. The agent reads and mutates it freely; no explicit save call is needed. Python agents receive the store as a real dict and mutate it the same way; the mutated dict is synced back automatically after each call. Lua agents receive it as a real table with the same automatic sync-back — with one caveat from Lua's single table type: a list the agent leaves empty serializes as {} rather than [] (keys that held arrays going in are restored to arrays), so agents in any language reading a shared store should tolerate both empty shapes.
The store starts as {} for a new conversation unless a default state file exists for this agent in the package root, in which case it starts as a copy of that file's contents.
The studio bridge object
The studio parameter is provided by the player. It gives the agent access to platform capabilities. Agents should check for the presence of methods before calling them, since different player implementations may support different subsets of the bridge API. Python agents call the same methods under the same camelCase names (await studio.callModel(...), await studio.context.readFile(...)), and Lua agents do too with :await() in place of the keyword (studio.callModel(...):await()) — this reference applies to all four languages verbatim.
studio.toast(message)
Shows a transient notification to the user. Does not appear in the conversation and is not sent to the model. Players that have no visible UI may ignore this call.
studio.log(message)
Records a persistent audit-trail entry. The entry is shown to the user (typically in the conversation alongside regular messages, but visually distinguished) and is preserved across reloads. It is not included in the messages array sent to the model. Use this for significant agent actions the user should be able to review — "Memory stored: user_name = Marcus", "Event fired: pizza_delivery".
studio.history.search(query)
Searches all conversation turns for turns whose content contains query (case-insensitive). Returns an array of matching turns. Each turn has the shape:
{ role: string, content: string, index: number }
index is the message's zero-based position in the conversation. Each message is indexed individually — index 0 is the first user message, index 1 is the first assistant response, index 2 is the second user message, and so on. The sequence is not guaranteed to alternate perfectly: a user message with no following assistant response is possible if a response failed or was deleted. Only messages with role "user" or "assistant" are returned; internal entries are excluded.
studio.history.range(start, end)
Returns the conversation turns from position start (inclusive) to end (exclusive), using the same zero-based index as search. Returns only "user" and "assistant" turns; internal entries within that range are omitted. context.conversationLength gives the total length; passing it as end retrieves all turns: studio.history.range(0, context.conversationLength).
studio.context.readFile(name)
Requires @api studio.context. Reads a file from the character's context directory by name and resolves to its text, or null if there's no such file. The name is relative to the context directory, which the player resolves through fileMappings. This is how an agent reads its data: composable lookup agents read the file named in context.config.contextFile, and dot-prefixed files (.cars.json) are the usual choice because the player keeps them out of the model's context block — see Chapter 3.
studio.callModel(systemPrompt, messages, maxTokens, opts)
Requires @api studio.model. Makes one non-streaming call to the user's configured model and resolves to the reply text.
systemPrompt— string.messages— array of{ role, content }with role"user","assistant", or"system"and string content; anything else is dropped. An empty array is allowed.maxTokens— reply cap; default 1024, clamped to 1–8192.opts— optional{ model, temperature, topP }.modelis"worker"(the default) or"main", which picks between the two models the user configured. Explicit model IDs aren't accepted — an agent spends the user's tokens, so the user picks the model.
Calls the model makes indirectly — from a tool call, a hook, or inside a sub-agent — count against the user's agent token caps. When a cap is exhausted the call rejects with a descriptive error; catch it and return a graceful result to the model rather than throwing. Calls from an @ command aren't metered, since the user chose to spend. Agents that declare studio.model get a 120-second call timeout instead of the usual 8 seconds.
studio.grants.readStore(ref)
Requires @api studio.grants. Resolves to a read-only snapshot of a granted agent's store. It only works for an agent that has grants — in practice the built-in studio.sub-agent, which uses it to expand %%STORE:ref%% in a sub-agent's prompt. ref matches a private grant by its short ID or implementation, or a shared grant by its ID. Writes go through the granted agent's own tools. See Sub-agents and granted tools.
studio.web.fetch(url, options) — optional
Fetches a web page and returns its readable text content. Requires @api studio.web in the agent's manifest — enforced by the sandbox. This is an optional host capability: not all players implement it. Character Studio stubs this method — every call rejects with a "not implemented" error. Agents that declare @api studio.web still load and run correctly in Character Studio; only the web fetch calls fail. Implementing hosts (such as Character Player) may provide the full implementation behind a per-character configuration gate.
options is optional. Supported option: maxChars — maximum characters of page text to return (default: 8000; implementing hosts clamp to [500, 30000]).
On success, resolves to:
{
url: string, // final URL after redirects
status: number, // HTTP status
contentType: string, // e.g. "text/html"
title: string|null, // <title> text for HTML pages, else null
text: string, // readable plain text (scripts/nav/images stripped)
truncated: boolean // true when text was cut at maxChars
}
Agent obligations when using studio.web:
try/catchevery call — rejection is a normal outcome, not a bug. The host may not implement the capability, or the fetch may fail for network or content-type reasons.- On failure, tell the user honestly that the page could not be read. Never fabricate page contents. Inventing content for a page that was never fetched is the failure mode this API was designed to prevent.
try {
const page = await studio.web.fetch(url, { maxChars: 8000 });
return `## ${page.title ?? url}\n\n${page.text}${page.truncated ? "\n\n[content truncated]" : ""}`;
} catch (err) {
return `Could not read ${url}: ${err.message}`;
// Do NOT return fabricated page content here
}
The context object
{
invokedBy: "model" | "direct" | "preload" | "hook", // tool call, @command, onLoad, or hook event
conversationLength: number, // total turns in conversation
characterName: string, // character name from config
agentId: string, // this agent's registered ID
config: object, // per-instance config (composed agents only, else {})
toolCallId: number, // model tool calls only: this call's id (see below)
}
toolCallId is present on tool calls that land in the conversation history: a call the model made, and the calls a sub-agent makes with its granted tools while answering one. It's an integer that only goes up within a conversation and is never reused there, including after Clear Chat. A fork continues from the source's numbering. An agent that records something because of a tool call can store the id with the record, so it can tell later which call created it. @ commands and hooks get no toolCallId.
agentId is the registered ID for this agent instance — useful for agents that log or generate IDs relative to themselves. config is the object from a composed agent entry in config.json (see the composable entry format below); for simple string-registered agents it is always {}. Composed agents use context.config to discover their configuration — for example, context.config.contextFile tells studio.context-access which file to search.
Bridge API versioning
Player implementations vary in which bridge methods they support. Agents that use optional bridge capabilities should check before calling:
// Defensive use of a capability that may not exist in all players
if (studio.conversation?.collapse) {
await studio.conversation.collapse(upToIndex, summaryText);
} else {
studio.log("This player does not support conversation collapse.");
}
This is the standard pattern for forward compatibility. Agents that require capabilities not present in the player should return a descriptive error string from onToolCall rather than throwing.
Library files
An agent that needs a third-party JavaScript library declares it with @lib in the manifest. The player loads the library file from agents/lib/{name}.js (falling back to agents/lib/{name}.min.js) and evaluates it in the agent's execution environment before the agent module loads. The library registers on the global scope via the IIFE/UMD pattern, making it available as a global to the agent code.
/**
* @agent mychar.battle-manager
* @lib jmespath
*/
// jmespath is now available as a global — no import needed
export function onToolCall(toolName, args, store, studio, context) {
const results = jmespath.search(store.units, args.filter);
return JSON.stringify(results);
}
Library format requirements: Library files must be IIFE (Immediately Invoked Function Expression) or UMD (Universal Module Definition) format. These formats self-register on globalThis when evaluated. ES module format (files using import/export as their primary interface) is not supported as library files. Most established libraries provide both an ES module build and a UMD/browser build — use the UMD or browser build.
Environment constraints: Library code runs in the same sandboxed context as the agent — no DOM, no fetch, no localStorage, no window. Pure utility libraries (data manipulation, parsing, math) work without issue. Libraries that require browser or network APIs do not.
Player responsibilities: A conformant player that supports the @lib field must locate agents/lib/{name}.js within the character package and evaluate it in the agent's execution context before evaluating the agent module. Players that run agents in isolated workers must evaluate libraries in the worker's global scope, not the main thread's. Libraries are never sent to the model and never affect the broker loop — they are purely an agent implementation detail.
Writing a library file
A library file wraps its exports in an IIFE and attaches them to globalThis. The pattern below is all that's required. A copy of this file ships as agents/lib/weighted-choice.js in Character Studio's agents/lib/ directory and can be used as a starting template.
/**
* agents/lib/weighted-choice.js
*
* Sample library: weighted random selection from a table.
* Declare with @lib weighted-choice in your agent manifest.
*
* weightedChoice.pick(table) — pick one entry by weight
* weightedChoice.pickN(table, n) — pick n entries with replacement
*
* Table format: [{ weight: number, value: any }, ...]
*/
(function (global) {
"use strict";
function pick(table) {
if (!Array.isArray(table) || table.length === 0)
throw new Error("weightedChoice.pick: table must be a non-empty array");
const total = table.reduce((sum, e) => sum + (Number(e.weight) || 1), 0);
let roll = Math.random() * total;
for (const entry of table) {
roll -= (Number(entry.weight) || 1);
if (roll < 0) return entry.value;
}
return table[table.length - 1].value;
}
function pickN(table, n) {
const results = [];
for (let i = 0; i < n; i++) results.push(pick(table));
return results;
}
global.weightedChoice = { pick, pickN };
}(typeof globalThis !== "undefined" ? globalThis : this));
The key points: wrap everything in an IIFE, accept global as a parameter, assign your public API to global.yourLibraryName, and pass typeof globalThis !== "undefined" ? globalThis : this as the argument. That's the entire pattern — no module system, no bundler, no build step required.
Agent example skeleton
/**
* @agent mycharacter.example
* @version 1.0
* @description A minimal example agent
*
* @tool do_something
* @tool-description Call this when something happens
* @tool-params {"type":"object","properties":{"what":{"type":"string"}},"required":["what"]}
*
* @command @example exampleCommand
*
* @preload true
*/
// store shape: { items: string[] }
export function onLoad(store, studio, context) {
if (!store.items?.length) return null;
return `## Example Items\n${store.items.map(i => `- ${i}`).join("\n")}`;
}
export function onToolCall(toolName, args, store, studio, context) {
if (toolName === "do_something") {
if (!store.items) store.items = [];
store.items.push(args.what);
studio.log(`Example: stored "${args.what}"`);
return `Stored: ${args.what}`;
}
return `[Unknown tool: ${toolName}]`;
}
export async function exampleCommand(args, store, studio, context) {
if (!args.trim()) return "Usage: example <something>";
if (!store.items) store.items = [];
store.items.push(args.trim());
studio.toast(`Stored: ${args.trim()}`);
}
---
Part 2 — Player Implementors
A player is any software that loads a character package, manages a conversation, and runs agents. This section describes what a conformant player must do.
Our reference implementation is the Character Player (character-player.html / character-player.js). It implements everything in this part, and its agent runtime — the worker sandbox in shared/worker-sandbox.js and the agent code in character-shared.js — is the same code Studio and PartyLine run. When this section and the Character Player disagree, treat it as a bug in one of them and let us know. For anything this section leaves open, the Character Player's behavior is the answer.
Agent languages in a package
Authors write agents in four languages, but a package only contains three. Export compiles TypeScript to JavaScript, so a player never sees a .ts file in a package. Python and Lua agents are not compiled — they ship as .py and .lua source and run in a real interpreter inside the player. A conformant player should therefore expect any mix of .js, .py, and .lua files in agents/.
We ship source because there is no faithful compiled form. TypeScript compiles to JavaScript losslessly; Python and Lua don't. Python-to-JS translators run a lookalike of Python rather than Python itself, and Lua-in-JS options are separate interpreters rather than compilers. An agent compiled that way could behave differently in the player than it did in Studio, which defeats the point of play-testing. Running the real interpreter (Pyodide for Python, Wasmoon for Lua) means a Python or Lua agent behaves the same everywhere it runs.
Supporting all three costs less than it sounds, because the language lives entirely inside the sandbox. The host side of a player never knows which language an agent is written in. It tags the load with language: "python" or language: "lua", and the rest — the bridge, @api enforcement, stores, metering, tool calls — is identical. A browser-based player that reuses our sandbox gets all three languages from:
shared/worker-sandbox.js— the sandbox worker and thestudiobridgeagents/python-executor.jsandagents/lua-executor.js— the language executors, loaded only when a package needs themjs/pyodide/(about 13 MB) andjs/wasmoon/(about 430 KB) — the bundled runtimes, also loaded only on demand
A player that chooses to support JavaScript agents only must refuse a character with .py or .lua agents and tell the user why, rather than loading it with those agents silently missing — a character with a missing agent runs, but its tools fail mid-conversation in ways that are hard to diagnose.
Loading a character package
On startup, the player:
- Reads
config.jsonand builds a path resolver fromfileMappings(see the Character Package section above). All subsequent file reads use this resolver — never hard-coded default paths. - Reads the character content files through the resolver: instructions, system prompt, description, intro, and all files in the context directory.
- Parses the
"agents"array and, for each entry, loads the agent file from the package'sagents/directory —.js,.py, or.lua, resolved in that order (see Agent languages in a package) — or, for sub-agent entries, the.subagent.mdprompt file. See Composed agent entries for object entries. - Parses each agent's manifest comment block to extract tools, commands, flags, declared
@apinamespaces, and hooks. Sub-agents get their tool definition from their frontmatter instead. - Registers the agent in load order. On any tool name or command name collision, the first registered agent wins and a warning should be logged. Later registrations for the same name are silently skipped.
- For agents with
@preload true, loads their persistent store (from the conversation's saved state, or from the default state file if this is a new conversation) and callsonLoad. Concatenates all non-null return values and sends the result as ausermessage withisFile: true, after the character context block.
The tool definitions array
Before each API call, the player builds a tools array from all registered agent manifests and includes it in the request body alongside the messages array. Each tool entry follows the OpenAI function-calling format:
{
"type": "function",
"function": {
"name": "tool_name",
"description": "From @tool-description",
"parameters": { /* From @tool-params — a JSON Schema object */ }
}
}
The broker loop
After sending the messages and tools to the LLM, the player enters the broker loop:
- Stream the response. If
finish_reasonis"stop", the loop ends — deliver the final response to the consumer. - If
finish_reasonis"tool_calls":- Append the assistant's tool-call message to the conversation:
{ role: "assistant", tool_calls: [...] } - For each tool call in the response, find the registered agent that owns that tool name. If no agent owns it, return a tool result message with an error string.
- Load that agent's store if not already loaded. Call
agent.onToolCall(toolName, args, store, studio, context). The returned string is the tool result. Save the (potentially mutated) store back to persistent storage. - Append a tool result message to the conversation:
{ role: "tool", tool_call_id: "...", content: "result string" }
- Append the assistant's tool-call message to the conversation:
- After all tool calls in this round are dispatched, go back to step 1 — rebuild the messages array and call the LLM again.
The loop terminates when the model returns a "stop" finish reason. Players should enforce a maximum iteration count (recommended: 10) to prevent runaway loops in the event of a misbehaving model or agent.
Hook events
Players that support agent hooks (see Hook events in Part 1) call them at these points, only for agents that both declare @hook <event> and export the handler:
conversationStart— the first time an agent's store is created for a conversation: when the store has no saved state for this conversation yet (seeded from the default file or empty). Call it before the agent's firstonLoad, tool call, or other hook, and save the store immediately so it never fires twice. Pass{ isNewConversation }.beforeResponse— after the user sends and before building the model request. Pass{ userText, regenerate, respondingToHuman }. If any hook ran, rebuild the preload context before building the request, so the model sees the change in this response.afterResponse— once per character response, after the broker loop finishes and the conversation is saved. Pass{ userText, responseText, toolCalls, stopped, toolsOffered }. Run these hooks before rebuilding the preload context for the next request, so the model sees what they changed. A response that produced nothing runs no hooks.afterToolCall— after each successful model-initiated tool call, for every other agent in the same scope. Pass{ agentId, toolName, args, result, toolCallId }. Do not notify the calling agent, do not notify after a failed call, and do not let the handler change the result sent to the model.afterUserEdit— after any user edit that removes history entries carrying tool-call ids, once the edit is saved and before any response it triggers is requested. A rejected response (Mark Unacceptable) stays in the file but counts as removed; don't report its ids again if it is later deleted. Pass{ deletedToolCallIds, lastViableToolCallId }: the removed calls' ids, sorted, and the highest id in the remaining non-rejected history. After a fork, fire it in the new conversation with the source's entries past the fork point as the removed set. Hold off sends while the hooks run, and don't regenerate if the user left the conversation meanwhile. When default agent data is saved, strip everytoolCallIdfield from it.
Call hooks one at a time in agent load order, save each store after its hook, and never let a hook failure interrupt the conversation — log it and continue. In multi-character play, scope both events exactly like tool dispatch, and never give a character's hooks a private message addressed to someone else. Players that don't support hooks simply never call them; agents are required to cope.
Rebuild the preload context (every @preload agent's onLoad) after every turn, not only at conversation load. Past tool results are typically dropped from the history sent to the model, so the preload block is the only place the model sees what its tools changed.
Persistent storage
The player is responsible for persisting:
- The conversation — the full message array for the current session.
- Agent stores — one JSON object per agent per conversation. The player saves the store after every agent call and loads it before the next call in the same session. How storage is implemented (files, IndexedDB, a server, etc.) is entirely the player's concern.
Agent stores are conversation-scoped. Switching to a different conversation gives each agent a fresh store (seeded from the default state file if one exists). An agent's store from one conversation is never visible to another.
The studio bridge object
The player constructs a studio object and passes it to every agent call. At minimum, a conformant player must implement:
| Method | Required | Behavior |
|---|---|---|
studio.toast(message) | Yes | Show a transient notification. Headless players may implement this as a no-op. |
studio.log(message) | Yes | Record a persistent audit entry in the conversation. Must survive reloads. Must not be sent to the model. |
studio.history.search(query) | Yes | Case-insensitive substring search over user/assistant turns. Returns [{ role, content, index }] where index is zero-based. |
studio.history.range(start, end) | Yes | Returns user/assistant turns in the given zero-based index range (start inclusive, end exclusive). index in each result is the position in the full conversation. |
studio.context.readFile(name) | Yes | Returns the text of a file in the context directory (resolved through fileMappings), or null. Dot-prefixed files must be readable here even though they're excluded from the model's context block. |
studio.callModel(systemPrompt, messages, maxTokens, opts) | Yes, for sub-agents | One non-streaming model call; resolves to the reply text. Map opts.model ("worker" / "main") onto the user's own model settings, never a package-supplied model ID. Meter it as described in Agent token caps. For a sub-agent with grants, run the inner tool loop described in Sub-agents and granted tools. |
studio.grants.readStore(ref) | Yes, for sub-agents | Returns a granted agent's store, resolved against the calling agent's grants only. Reject when the caller has no grants or nothing matches ref. |
studio.web.fetch(url, options) | No — optional | Fetches a web page and returns readable text. Players that do not provide web access must reject with an error message containing the phrase "not implemented". Character Studio uses this stub response. See rejection conditions below. |
studio.web rejection conditions
Players that implement studio.web.fetch must reject (throw) for the conditions below. Players that do not implement it must always reject with the canonical "not implemented" response. Agents must catch all of these — see Part 1.
| Condition | Error message must contain |
|---|---|
| Host does not provide web access | "not implemented" — the canonical stub response |
Agent did not declare @api studio.web | Enforced by the sandbox before the host is reached |
| Invalid URL or non-http(s) scheme | "not a valid URL" or "only http(s)" |
| Private-network target (SSRF protection) | "blocked" |
| Timeout, network failure, or unsupported content type | Host-specific detail |
Additional bridge methods may be added in future API versions. Players should implement new methods as they are defined, but agents must not require them without defensive capability checks (see Part 1).
The context object
The player constructs a fresh context object for each agent call:
{
invokedBy: "model" | "direct" | "preload" | "hook",
conversationLength: number,
characterName: string,
agentId: string, // registered ID of the agent being called
config: object, // per-instance config from composed entry, or {}
toolCallId: number, // only for tool calls that land in the history
}
invokedBy is "model" for broker-loop tool calls, "direct" for command dialog invocations, "preload" for onLoad, and "hook" for hook events. conversationLength is the number of entries in the conversation at the time of the call. characterName is from config.json. agentId is the registered ID for this instance. config is the full object from a composed agent entry in config.json; for simple string-registered agents it is {}.
toolCallId: give every model-initiated tool call that reaches an agent, and every granted inner call a sub-agent makes during one, the conversation's next integer id, starting at 1. Keep the counter per conversation (Studio uses a conversation_NN.tool-call-ids.json sidecar holding {"last": N}). Never hand an id out twice in a conversation: a clear keeps the counter, and a fork copies it. Record the id beside the call in the stored history, so a later history edit can tell which ids it removed. Calls started by an @ command get no id, and neither do calls that fail before reaching an agent (unknown tool, invalid arguments).
Agent token caps
Agents can spend the user's tokens through studio.callModel, so a player enforces two budgets on agent-initiated calls:
- Per response — tokens agent calls may spend within one response (one broker-loop cycle). Studio and the Character Player default to 32,000.
- Per conversation — tokens agent calls may spend over the life of one conversation. Persist the running total with the conversation (Studio uses a sidecar file). Default 1,000,000.
Both caps come from the player user's own settings — never from config.json or anything else in a character package. The metering rule is "chains rooted in a model decision meter; chains rooted in a user action don't": meter calls whose invokedBy is "model" or "hook", and every call made inside a sub-agent's inner loop. Don't meter calls from @ commands. Check both caps before each call and reject with a descriptive message ("budget exhausted for this response") when either is spent; inside a sub-agent's inner loop, check between rounds and end the loop with a note instead of failing the whole answer. The user-facing rules are in Chapter 8.
Sub-agents and granted tools
A sub-agent is an agent defined by a prompt file (name.subagent.md: YAML frontmatter plus a prompt body) instead of code. The model calls it like any other tool; it answers by making its own model call. The runtime is itself an agent — studio.sub-agent, which export copies into the package — so most of the work happens in ordinary agent code through studio.callModel, studio.context.readFile, studio.history, and studio.grants. The player's part:
- Registration. For each sub-agent entry, read the prompt file from
agents/, parse and validate its frontmatter, apply any per-instance overrides from theconfig.jsonentry, and register an instance ofstudio.sub-agentwhose tool definition comes from the frontmatter. Pass{ meta, promptBody }ascontext.config. - Grants. A sub-agent entry can list
shared-agents(references to existing top-level instances) andprivate-agents(fresh declarations, instantiated privately with their stores namespaced under the sub-agent's ID). Load the private instances, resolve both lists, and build the set of granted tools. - The inner loop. When a sub-agent with grants calls
studio.callModel, send the granted tools with the request. While the model returns tool calls, dispatch each one to the granted agent that owns it (with its own store and the same@apienforcement), append the results, and call again — up to the frontmatter'smaxRounds(default 5, maximum 10). Every round is metered, and every granted call gets atoolCallId. Resolve with the final reply text.
The frontmatter fields, macros (%%CONTEXT%%, %%HISTORY%%, %%STORE:ref%%, and the rest), history tiers, and grant semantics are specified in Chapter 8. The parsing and validation (parseSubAgentFrontmatter, validateSubAgentMeta, buildSubAgentRegistration in character-shared.js) are shared by all three of our apps, and porting them is the fastest way to match their behavior exactly.
Agent sandboxing
Agent code is arbitrary JavaScript provided by character authors. Players that run agents in the main application thread accept full trust of that code. Players that serve public-facing deployments with characters from unknown authors should run each agent in an isolated execution context (such as a Web Worker or equivalent) with the following constraints:
- No DOM access
- No network access (
fetch,XMLHttpRequest, WebSocket) - No access to other agents' stores
- Enforced execution timeout
- Bridge methods provided only via a structured message-passing channel, not as direct references
The @api manifest declarations are intended to become permission gates in this model — an agent that declares @api studio.history receives history methods over the channel; one that does not cannot call them even if it tries. This enforcement is not required in trusted (single-author) deployments.
Python agents ride the same sandbox: Studio and the web player run them in a CPython-on-WebAssembly interpreter (Pyodide) inside the same worker, behind the same message protocol and @api gates. A player adding Python support hosts the runtime and routes language: "python" loads to it; the agent-facing contract is unchanged. Packages ship .py source as-is — there is no compiled form (see Agent languages in a package).
Lua agents follow the identical pattern with a far smaller runtime: real Lua 5.4 on WebAssembly (Wasmoon, ~420 KB), loaded lazily behind language: "lua" loads, with the agent environment restricted to the portable subset (no io, no require/load, no debug — the bridge is the only door). Packages ship .lua source as-is.
Part 3 — Character Consumers
A consumer is any website or application that embeds a character using a player library. This section describes the interface a player library should expose to consumers, and what consumers need to do to integrate it.
We don't ship a standalone player library yet, so the CharacterPlayer interface below describes the intended future contract that consumer integrations can be written against. In the meantime, there are two ways to put a character in front of people today:
- Link to our player. The Character Player we host at characterbrowser.app/player loads a character straight from a URL — host the exported zip anywhere that serves files over HTTPS (with CORS allowed) and link to
https://characterbrowser.app/player/?loadCharacter=<the zip's URL>. This takes no integration work, but every visitor needs their own API key. See Sharing Characters by URL in Chapter 12. - Integrate the reference implementation. The Character Player is our reference implementation of everything in Part 2 — package loading,
fileMappings, the broker loop, hooks, persistence, the bridge, and sandboxed agents in JavaScript, Python, and Lua. Running it inside a company's own site is how subscribers get to play characters on the plan they already have, with model calls routed through the company's own sign-in and backend instead of a user-supplied API key. It's a working starting point to adapt, rather than building a player from the contracts alone.
Initialization
A consumer provides a character package and API credentials. The player handles everything else.
const player = new CharacterPlayer({
package: "/path/to/character-package", // or a pre-loaded package object
apiKey: "your-api-key",
baseUrl: "https://api.venice.ai/api/v1", // optional override
model: "model-id", // optional override
});
Sending a message
The consumer sends a user message and receives the completed assistant response. All tool calls, broker loop iterations, and agent activity happen internally — the consumer sees only the final response.
const response = await player.send("Hello, how are you?");
// response: { role: "assistant", content: "I'm doing well..." }
Events
Consumers can subscribe to player events for richer integration:
| Event | Payload | When |
|---|---|---|
message | { role, content } | Final assistant response delivered |
token | string | Streaming token (if streaming is enabled) |
agentLog | string | An agent called studio.log() |
agentToast | string | An agent called studio.toast() |
toolCall | { agentId, toolName, result } | An agent tool call completed |
error | Error | API error or agent error |
Conversation management
// Start a new conversation (existing conversation is saved automatically)
await player.newConversation();
// Get the current conversation as an array of turns
const turns = player.getConversation();
// Load a previously saved conversation by ID
await player.loadConversation(id);
// List all saved conversations for this character
const list = await player.listConversations();
What consumers do not need to know
The agent system is entirely internal to the player. Consumers do not need to know which agents a character uses, how to configure them, or that tool-calling is happening at all. A consumer that only uses player.send() and listens for message events gets a fully functional character without any awareness of the underlying machinery.