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 itself currently implements all three roles in a single application: it is both the editing environment and the player. This chapter is written for the future state in which these roles are separated — where characters are portable packages that run in standalone players. The APIs described here are the contracts that make that portability possible.
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 modules in the character pkg) │
│ 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. 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
...
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"
],
"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 agent IDs in load order. The player loads each agent from the agents/ directory within the package as {agentId}.js. 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.
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 which bridge API namespaces this agent uses. Currently informational; will become a permission gate when agents run in sandboxed workers. |
@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. |
@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.
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.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", // tool call or @command
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 {})
}
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. Character Studio is one player implementation. This section describes what a conformant player must do.
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 agent ID, loads the corresponding.jsfile from the package'sagents/directory. - Parses each agent's manifest comment block to extract tools, commands, and flags.
- 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.
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.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",
conversationLength: number,
characterName: string,
agentId: string, // registered ID of the agent being called
config: object, // per-instance config from composed entry, or {}
}
invokedBy is "model" for broker-loop tool calls and "direct" for command dialog invocations. 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 {}.
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.
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.
No standalone player library currently exists — Character Studio implements the player internally. This section describes the intended future interface so that consumer integrations can be written against a stable contract as the library is developed.
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.